前言
在Java后台开发或者Android开发中,synchronized出现的频率并不算低。本文就什么是synchronized,如何使用synchronized以及synchronized的实现原理做深入的讲解,揭开synchronized神秘面纱,有助于大家掌握synchronized的用法并深谙synchronized深层原理,提升并发编程能力,写出更高性能的程序。
什么是synchronized
synchronized在Java中是以关键字的形式出现,英文直译多来就是“同步”,也被大家称作为同步锁或互斥锁。
synchronized使用的场景都是在多线程并发的情况下,用来控制同步代码(方法)的执行,确保同一时刻只能有一个线程执行同步代码(方法),从而保证了共享资源在多线程下的数据一致性。
下面用代码举个例子。在一个类中定义一个int变量和一个对该变量进行累加的方法,并实例化出一个类的对象,然后起两个线程分别在线程中对同一个对象执行10000次累加操作,最后输出对象中count的值。
如果不去考虑并发编程的原子性和可见性导致的线程安全的问题,经过2个线程各10000次累加,最后的结果必然是20000,可是实际的输出结果往往到不了20000。 这就是多线程下同时去操作共享资源而导致的数据不一致性。设想下这个count如果代表的是商品的库存,多线程代表商家补货操作,这势必会造成严重的错误。那么synchronized的出现就有效了解决了此为数据不一致的问题。我们只需在add()方法前加一个synchronized修饰,如下图:
这次的输出结果就准确无误了。
synchronized的作用
- 内存可见性:确保当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。
- 操作原子性:确保整个操作(可以是一个或多个步骤)作为一个整体被执行,其顺序不会被打乱或只执行部分操作。
这里值得一提的是synchronized也是能够确保内存可见性(很多人不知道这点,以为只有volatile能保证可见性),但是synchronized无法禁止编译器或者处理器对指令的重排序,无法保证指令的有序性,必要的时候需要volatile关键字一起配合使用。
synchronized的使用方式
synchronized有两种使用方式,也可以说有三种,这里就都列出来:
- 修饰代码块:使用格式为
synchronized(lock){//代码块}
,其中lock
是一个对象,被修饰的代码块称为同步代码块。当一个线程访问一个对象中的synchronized(lock)
同步代码块时,其他试图访问该对象的同步代码块的线程将被阻塞。
public void add() {
synchronized(lock) {
count++;
}
}
- 修饰方法:使用格式为
public synchronized void methodName(){//方法体}
,被修饰的方法称为同步方法。同步方法的作用范围是整个方法,作用对象是调用这个方法的对象。
public synchronized void add() {
count++;
}
- 修饰静态方法:使用格式为
public synchronized void methodName(){//方法体}
,被修饰的方法称为同步静态方法。同步静态方法的作用范围是整个方法,作用对象是静态方法所在类的class对象。
public static synchronized void add() {
count++;
}
这里要解释下这个修饰代码块时传入的这个lock对象。很多新手开发可能对这个lock对象都很迷,不知道这个经常被叫做对象锁的东西是干什么的,也不知道什么传。
有时候同步代码块里会直接传this(指向当前对象),有时候又会去实例化一个lock对象(new一个Object)传入。
public void add() {
synchronized(this) {
count++;
}
}
Object lock = new Object();
public void add() {
synchronized(lock) {
count++;
}
}
这又有什么区别呢?其实这里只要确保需要同步的代码块或者方法使用的是同一个对象锁就可以。
下面代码的这种方式是无法确保同步代码(add方法和reduce方法内同步代码块种的代码)的互斥访问的,原因是两个同步代码块使用的不是同一个对象锁。
Object lock = new Object();
public void add() {
synchronized(lock) {
count++;
}
}
public void reduce() {
synchronized(this) {
count--;
}
}
同步实例方法(对象方法)使用的对象锁就是调用该方法的实例(对象)。
public class ConcurrentDeome_2 {
// 多线程共享的变量
private int count;
//同步方法
public synchronized void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
ConcurrentDeome_2 accumulator = new ConcurrentDeome_2();
// add()同步方法使用的对象锁就是accumulator指向的对象
accumulator.add();
}
}
同样同步静态方法使用的对象锁是该方法的类的class对象。
synchronized如何被编译
大家应该知道,Java代码会被Java虚拟机编译成虚拟机能识别的字节码指令,当然字节码人类是没法阅读的,不过可以通过javap命令进行反编译出汇编指令就能读懂了。
我们先来对普通的add方法进行Javap反编译。
public void add() {
count++;
}
得到以下结果:
public void add();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #17 // Field count:I
5: iconst_1
6: iadd
7: putfield #17 // Field count:I
10: return
然后再对有synchronized修饰方法中的代码块的代码进行Javap反编译。
public void add() {
synchronized(this) {
count++;
}
}
得到以下结果:
public void add();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=2, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: dup
6: getfield #17 // Field count:I
9: iconst_1
10: iadd
11: putfield #17 // Field count:I
14: aload_1
15: monitorexit
16: goto 22
19: aload_1
20: monitorexit
21: athrow
22: return
Exception table:
from to target type
4 16 19 any
19 21 19 any
乍一看多了加了一个synchronized包裹,多了好多字节码指令。后者将一系列的指令包裹在了指令monitorenter和monitorexit之间。没错,在这个字节码示例中,字节码指令monitorenter和monitorexit用于实现同步代码块的进入和退出。
为什么会有两个monitorexit指令?这通常是因为字节码中包含了异常处理逻辑。在Java字节码中,为了确保即使在发生异常的情况下也能正确释放锁,编译器会生成一个异常表(Exception table),并在异常处理路径上插入额外的monitorexit
指令。
再来看看synchronized修饰方式时Javap反编译出的字节码指令。
public synchronized void add() {
count++;
}
得到以下结果:
public synchronized void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #17 // Field count:I
5: iconst_1
6: iadd
7: putfield #17 // Field count:I
10: return
方法的Code下还是和普通方法时反编译出来的结果一样,并没有看到有monitorenter和monitorexit指令,但是在flags下多了一个ACC_SYNCHRONIZED标识。Java虚拟机就是从方法常量池中的method_info Structure
方法表结构中,靠ACC_SYNCHRONIZED
访问标志来区分一个方法是否为同步方法。
Java虚拟机(JVM)的锁机制
到这里我们已经知道当Java代码中使用synchronized
关键字时,Java编译器会将其编译成特定的字节码指令,以确保同步的正确实现。在字节码层面,synchronized
的实现涉及到JVM的锁机制。
JVM的锁机制和上面提到的进入和退出monitor的两条指令息息相关。要理解Java虚拟机的锁机制的前提是认识这个monitor。
monitor直译过来就是监视,我们通常称它为监视器锁也可以称作管程(学过操作系统的应该对它不陌生),它以对象的形式存储于Java堆内存中。Java中每个对象都伴生着自己的Monitor对象,每一个synchronized修饰的同步代码块使用的对象锁或者调用synchronized修饰的方法的对象都会和一个monitor关联(对象锁的对象头的MarkWord中的ptr_to_heavyweight_monitor指向堆内存中monitor的起始地址)。可以说Java
对象头是synchronized
底层实现的关键要素。
个人觉得monitor可以理解为用来管理线程同步(管程嘛)。不同的Java虚拟机可能对monitor的实现略有差异,通常来说monitor下面有这么几个字段(以下是伪代码,只列部分字段):
Monitor {
_count = 0; //记录个数
_recursions = 0; //重入次数
_waiters = 0; //等待线程数
_owner = null; //指向当前所属的线程
}
知道了什么是monitor后,现在开始讲解Java虚拟机实现锁机制的重点:
线程执行monitorenter指令尝试去获取监视器锁的虚拟机层的实现就是先会去判断Monitor对象下count字段的值是否为0,如果count等于0,将count加1,owner赋值当前线程起始地址,说明成功获取锁,然后开始执行加锁部分的同步代码指令;如果count不等于0,并且owner并没有指向线程自己,那么此时线程将进入阻塞状态(可以理解为被丢到了一个阻塞队列中去了),等待锁的释放;还有一个情况是如果count不等于0,并且owner指向线程自己,此时是允许该线程再次获取锁的,count再加1,recursions也会加1(synchronized是可重入锁)。
线程执行monitorexit指令释放锁,Monitor对象的count减1,如果是重入锁recursions也跟着自减,owner置空。此时Monitor对象没有被任何线程占有,在阻塞队列中等待获取锁的线程都有机会获取到锁(synchronized是非公平锁)。对于非公平锁的解释就是:当一个线程释放锁时,不一定是等待时间最长的线程获得锁,而是由Java虚拟机随机选择一个等待线程来获取锁。这样就存在某些线程等待时间较长的情况,导致不公平。
此外,还有一个关键的字段waiters,当线程在执行同步的代码时,调用了锁对象的wait()方法,此时线程也会释放锁,Monitor的waiters加1,线程被丢到一个等待队列,直到调用了锁对象的notify()或notifyAll()方法后,线程才会从等待队列中被移除并添加到阻塞队列和其它线程竞争获取锁。
到这里Java虚拟机的锁机制已经将的差不多了,这种机制的作用是确保在多线程环境下,能够避免多个线程同时访问同一个共享资源,从而防止线程安全问题。当一个线程试图访问一个被另一个线程占用的对象时,它会被阻塞,直到监视器被释放为止。
synchronized的缺点
synchronized是非公平锁,无法做到先到先得。
前面说的Monitor监视器锁的本质是依赖于底层操作系统的Mutex Lock实现,因此虚拟机在执行获取锁和释放锁的指令时操作系统需要进行用户态线程和内核态线程的相互切换,频繁的切换势必会损耗性能影响程序执行速度。因此,早期的synchronized
属于重量级锁,存在效率低下的问题。
JDK1.6对synchronized的优化
JDK 1.6对synchronized
进行了多项优化,这些优化主要是为了提升多线程环境下同步代码的性能。以下是JDK 1.6对synchronized
的主要优化:
- 自适应自旋锁:
- 在JDK 1.5及以前,
synchronized
关键字的锁机制在竞争锁时,如果当前线程没有获取到锁,就会进入阻塞状态,直到获取到锁。这种方式存在线程挂起和恢复的开销,在竞争激烈的情况下会频繁发生,从而严重影响性能。 - 在JDK 1.6中,引入了自适应自旋锁,即当线程尝试获取锁失败时,不是立即阻塞,而是采用自旋的方式等待一段时间,再次尝试获取锁。如果在这段时间内锁被释放了,那么当前线程就可以获取到锁,避免了线程挂起和恢复的开销。
- 自适应自旋锁会根据历史信息(如自旋成功的次数和失败的比例)来动态调整自旋的次数,如果自旋成功率高,则增加自旋次数,反之则减少自旋次数,甚至直接阻塞。
- 在JDK 1.5及以前,
- 锁消除:
- 锁消除是JIT编译器在编译时,对代码进行扫描,如果发现某些共享数据不可能被并发修改,那么就可以将其上的锁消除掉,从而减少不必要的同步开销。
- 锁粗化:
- 如果存在连续多次对同一个对象加锁和解锁的操作,JDK 1.6会将这些锁操作合并成一个更大的锁范围,以减少锁操作的次数,这就是锁粗化。
-
synchronized
锁的膨胀,其主要目的是为了提高同步控制的效率。这个过程涉及到锁状态的升级,从低级的锁状态升级到高级的锁状态:
-
无锁状态:当一个线程尝试获取一个对象的锁时,如果该对象的锁状态为无锁状态(unlocked),则进入下一步。
- 偏向锁:
- 偏向锁是JDK 1.6引入的一种锁优化机制。它的核心思想是,如果一个线程获得了某个对象的锁,那么在下一次访问该对象时,就无需再次进行锁的竞争,而是直接偏向这个线程。
- 在偏向锁的获取过程中,首先会检查对象头中的锁标志位是否为偏向模式。如果是,则检查持有偏向锁的线程是否就是当前线程。如果是,则直接获得锁;如果不是,则通过CAS操作尝试获取锁。如果CAS失败,则偏向锁升级为轻量级锁。
- 轻量级锁:
- 如果偏向锁获取失败,线程会尝试获取轻量级锁。此时,会使用CAS操作将对象的锁状态设置为轻量级锁状态(lightweight locked),并将当前线程ID记录在对象头中。
- 如果CAS操作成功,则当前线程就拥有了该对象的轻量级锁,并可以直接访问该对象。
- 如果CAS操作失败,表示该对象已经被其他线程获取了轻量级锁。此时,当前线程会进入自旋状态,使用自旋锁等待其他线程释放该对象的轻量级锁。
- 锁膨胀为重量级锁:
- 如果自旋等待超过了一定的次数或时间,JVM就会将该对象的锁状态升级为重量级锁状态(heavyweight locked)。此时,当前线程会进入阻塞状态,并将自己加入到该对象的等待队列中,等待其他线程释放该对象的锁。
- 重量级锁会阻塞、唤醒请求加锁的线程,它针对的是多个线程同时竞争同一把锁的情况。
这些优化使得JDK 1.6中的synchronized
关键字在多线程环境下表现更为出色,提高了并发性能。需要注意的是,虽然这些优化能够提升性能,但并发编程仍然是一个复杂的领域,需要谨慎处理同步和锁的使用,以避免出现死锁、活锁等问题。