ADeveloperH Blog

Meet a better self !

Android 周边技术分享


Java并发编程:Synchronized及其实现原理

1 Synchronized的基本使用

在并发编程中,多线程同时并发访问的资源叫做临界资源,当多个线程同时访问对象并要求操作相同资源时,分割了原子操作就有可能出现数据的不一致或数据不完整的情况,为避免这种情况的发生,我们会采取同步机制,以确保在某一时刻,方法内只允许有一个线程。

采用 synchronized 修饰符实现的同步机制叫做互斥锁机制,它所获得的锁叫做互斥锁。每个对象都有一个 monitor (锁标记),当线程拥有这个锁标记时才能访问这个资源,没有锁标记便进入锁池。任何一个对象系统都会为其创建一个互斥锁,这个锁是为了分配给线程的,防止打断原子操作。每个对象的锁只能分配给一个线程,因此叫做互斥锁。

Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized的作用主要有三个:

  • 确保线程互斥的访问同步代码
  • 保证共享变量的修改能够及时可见
  • 有效解决重排序问题。

从语法上讲,Synchronized总共有三种用法:

(1)修饰代码块

(2)修饰普通方法

(3)修饰静态方法

具体的使用方法之前Java多线程安全典型案例已经具体分析过。

2 Synchronized 原理

我们先通过反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的:

package com.paddx.test.concurrent;

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

反编译结果:

关于这两条指令的作用,我们直接参考JVM规范中描述:

monitorenter :

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.

这段话的大概意思为:

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit: 

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

这段话的大概意思为:

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

我们再来看一下同步方法的反编译结果:

源代码:

package com.paddx.test.concurrent;

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

反编译结果:

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

3 synchronized 的另个一重要作用:内存可见性

加锁(synchronized 同步)的功能不仅仅局限于互斥行为,同时还存在另外一个重要的方面:内存可见性。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且还希望确保当一个线程修改了对象状态后,其他线程能够看到该变化。而线程的同步恰恰也能够实现这一点。

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。为了确保所有的线程都能看到共享变量的最新值,可以在所有执行读操作或写操作的线程上加上同一把锁。下图示例了同步的可见性保证。

当线程 A 执行某个同步代码块时,线程 B 随后进入由同一个锁保护的同步代码块,这种情况下可以保证,当锁被释放前,A 看到的所有变量值(锁释放前,A 看到的变量包括 y 和 x)在 B 获得同一个锁后同样可以由 B 看到。换句话说,当线程 B 执行由锁保护的同步代码块时,可以看到线程 A 之前在同一个锁保护的同步代码块中的所有操作结果。如果在线程 A unlock M 之后,线程 B 才进入 lock M,那么线程 B 都可以看到线程 A unlock M 之前的操作,可以得到 i=1,j=1。如果在线程 B unlock M 之后,线程 A 才进入 lock M,那么线程 B 就不一定能看到线程 A 中的操作,因此 j 的值就不一定是 1。

现在考虑如下代码:

public class  MutableInteger  
{  
    private int value;  

    public int get(){  
        return value;  
    }  
    public void set(int value){  
        this.value = value;  
    }  
}

以上代码中,get 和 set 方法都在没有同步的情况下访问 value。如果 value 被多个线程共享,假如某个线程调用了 set,那么另一个正在调用 get 的线程可能会看到更新后的 value 值,也可能看不到。

通过对 set 和 get 方法进行同步,可以使 MutableInteger 成为一个线程安全的类,如下:

public class  SynchronizedInteger  
{  
    private int value;  

    public synchronized int get(){  
        return value;  
    }  
    public synchronized void set(int value){  
        this.value = value;  
    }  
} 

对 set 和 get 方法进行了同步,加上了同一把对象锁(this),这样 get 方法可以看到 set 方法中 value 值的变化,从而每次通过 get 方法取得的 value 的值都是最新的 value 值。

4 总结说明

  1. 如果同一个方法内同时有两个或更多线程,则每个线程有自己的局部变量拷贝。

  2. 类的每个实例都有自己的对象级别锁。当一个线程访问实例对象中的 synchronized 同步代码块或同步方法时,该线程便获取了该实例的对象级别锁,其他线程这时如果要访问 synchronized 同步代码块或同步方法,便需要阻塞等待,直到前面的线程从同步代码块或方法中退出,释放掉了该对象级别锁。

  3. 访问同一个类的不同实例对象中的同步代码块,不存在阻塞等待获取对象锁的问题,因为它们获取的是各自实例的对象级别锁,相互之间没有影响。

  4. 持有一个对象级别锁不会阻止该线程被交换出来,也不会阻塞其他线程访问同一示例对象中的非 synchronized 代码。当一个线程 A 持有一个对象级别锁(即进入了 synchronized 修饰的代码块或方法中)时,线程也有可能被交换出去,此时线程 B 有可能获取执行该对象中代码的时间,但它只能执行非同步代码(没有用 synchronized 修饰),当执行到同步代码时,便会被阻塞,此时可能线程规划器又让 A 线程运行,A 线程继续持有对象级别锁,当 A 线程退出同步代码时(即释放了对象级别锁),如果 B 线程此时再运行,便会获得该对象级别锁,从而执行 synchronized 中的代码。

  5. 持有对象级别锁的线程会让其他线程阻塞在所有的 synchronized 代码外。

    例如,在一个类中有三个synchronized 方法 a,b,c,当线程 A 正在执行一个实例对象 M 中的方法 a 时,它便获得了该对象级别锁,那么其他的线程在执行同一实例对象(即对象 M)中的代码时,便会在所有的 synchronized 方法处阻塞,即在方法 a,b,c 处都要被阻塞,等线程 A 释放掉对象级别锁时,其他的线程才可以去执行方法 a,b 或者 c 中的代码,从而获得该对象级别锁。

  6. 使用 synchronized(obj)同步语句块,可以获取指定对象上的对象级别锁。obj 为对象的引用,如果获取了 obj 对象上的对象级别锁,在并发访问 obj 对象时时,便会在其 synchronized 代码处阻塞等待,直到获取到该 obj对象的对象级别锁。当 obj 为 this 时,便是获取当前对象的对象级别锁。

  7. 类级别锁被特定类的所有示例共享,它用于控制对 static 成员变量以及 static 方法的并发访问。具体用法与对象级别锁相似。

  8. 互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。synchronized 关键字经过编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁,如果获得了锁,把锁的计数器加 1,相应地,在执行 monitorexit 指令时会将锁计数器减 1,当计数器为 0 时,锁便被释放了。由于 synchronized 同步块对同一个线程是可重入的,因此一个线程可以多次获得同一个对象的互斥锁,同样,要释放相应次数的该互斥锁,才能最终释放掉该锁。

  9. Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。传送门
更早的文章

Java使用同步解决线程安全问题的弊端及带来的问题

1 多线程使用同步解决方案引入的问题由于多线程会引入线程安全问题,我们使用了同步或者加锁的方法来解决这个问题,但是使用同步的方法也会带来相应的弊端。主要有如下两个弊端: 使用同步的方法效率低 如果出现同步嵌套,会出现死锁问题。对于第一个问题,告诉我们要慎用同步,只在必要的位置添加同步方法,而不是为了安全添加不必要的同步,这样会导致效率的降低,因为如果使用了同步会有锁机制,执行相关代码前会对锁进行相应的检查,这样就会降低效率。第二个问题是主要讲解的问题,这个我们需要在程序中尽量避免,如果...…

继续阅读