synchronized的原理与应用
1. 简介
在
Java中,我们经常使用synchronized来保证线程安全,其可以保证方法或者代码块在运行时,同一时刻只有一个线程可以进入到临界区(即被synchronized修饰的代码),同时它还可以保证共享变量的内存可见性。
2.实现原理
在Java 中,synchronized 通过修饰任意一个对象(非空),可以将其锁对象:
- 普通同步方法,锁是调用该方法的当前实例对象
- 静态同步方法,锁是当前类的class对象
- 同步方法块,锁是括号里面的对象
public class SynchronizedTest {
public synchronized void test1(){
}
public static synchronized void test2() {
}
public void test3(){
synchronized (this){
}
}
}

2.1 同步代码块
通过反汇编输出,可以看出,对于同步代码块,在进入代码块和退出代码块的地方插入了 monitorenter和monitorexit指令,并且在由于异常而导致退出的地方也插入了monitorexit指令,这样就不存在由于程序出现错误导致锁无法释放的情况。
2.1.1 monitorenter
进入对象的监视器
每个对象都与一个监视器关联。当且仅当线程拥有监视器的所有者(与监视器关联的对象)时,监视器才会被锁定。执行monitorenter的线程,尝试获取与 objectref(锁对象) 关联的监视器的所有权,如下所示:
- 如果与
objectref关联的监视器的计数为零,则线程进入监视器并将其计数设置为1,然后该线程是监视器的所有者。 - 如果线程已经拥有与
objectref关联的监视器 ,它将重新进入监视器,增加其计数。 - 如果另一个线程已经拥有与
objectref关联的监视器 ,则线程将阻塞,直到监视器的条目计数为零,然后再次尝试获得所有权。
如果是objectref是null,则monitorenter抛出NullPointerException。
2.1.2 monitorexit
退出对象的监视器
执行mnitorexit的线程必须是与objectref引用的实例关联的监视器的所有者 。
线程递减与objectref关联的监视器的计数。因此如果其计数的值为零,则线程退出监视器并且不再是其所有者。此时,被阻止进入监视器的线程可以尝试获取监视器。
2.2 同步方法
通过反汇编输出,可以看出,同步方法并没有使用 monitorenter 和 monitorexit 指令,而是通过将方法的字段访问标志添加ACC_SYCHRONIZED来实现。



可以看到,在同步方法中,其具有ACC_SYNCHRONIZED标志。当 JVM 发现方法具有该标志时,使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象,然后执行相应的代码,本质同 monitorenter 和 monitorexit。
3. Monitor
在上面的分析中,我们知道每一个非空的对象都有一个引用指向 Monitor 对象,那么什么是 Monitor 对象呢?
object monitor 是任何一个对象都有的内置的数据结构,它是用来协调使用当前对象的多个线程之间的执行顺序的(wait/notify),线程会block或者wait在一个对象的监视器上;锁是对对象访问的时候,通过对对象加锁,防止并行访问的控制手段;
只有加锁成功,才能拿到 object monitor (对象监视器),如果加锁失败,会进入对象监视器的entry队列里。
对象加锁成功后调用wait方法,也会wait在object monitor上,但是会释放锁,并进入(monitor wait list),当前线程也就WAITING (on object monitor)。 一旦对象被调用notify的,会重新尝试加锁,成功可以执行,否则进入(monitor entry list)。
锁和监视器的关系:拿到对象的监视器,肯定是对对象加锁成功的;对对象加锁成功 ,程序可以主动Watiing或者Time_waiting在对象监视器上。

线程使用对象的方式:
1、直接使用,为了避免多个线程同时使用,需要加锁,加锁成功的可以使用对象;
2、需要对象到达某种状态才能用,这时候需要调用对象的wait方法,线程挂起(wait)在对象的object monitor 监视器上,等待其他线程notify,可能多个线程都在wait,所以notify的时候多个等待的线程需要再次获取锁 ,这就解释了为什么wait的时候需要先加锁,notify的时候也需要加锁成功。
4. Java 对象头
一个对象的结构分为3部分,对象头、实例数据以及对其填充。
synchronized用的锁是存在Java对象头里的,Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中Class Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。
4.1 MarkWord
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

Mark Word会随着程序的运行发生变化,虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间。

5. 锁优化
jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级(书上这样说,但并不准确,如轻量级锁释放后变为无锁状态),这种策略是为了提高获得锁和释放锁的效率。
5.1 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。
所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
5.2 适应自旋锁
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
5.3 锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。
变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。
5.4 锁粗化
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。
6. 锁升级
synchronized 一直被称为重量级锁,因为其是靠操作系统互斥量实现线程的阻塞和唤醒,频繁的从用户态切换到内核态,浪费大量资源,为此,对 synchronized 进行了一系列的优化,使其尽可能少的从用户态切换到内湖太。
6.1 偏向锁
经研究,大多数情况下,锁不存在多线程竞争,且总是由同一个线程多次获得。为了降低线程获得锁的代价而引入了偏向锁。·
偏向锁工作原理
当一个线程访问同步块并获取锁后,会在锁对象的对象头存储当前获取锁的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,则表示当前线程已经获得锁,不需要重新获得锁。测试失败,则需要测试Mark Word中偏向锁的标识是否为1(表示当前为偏向锁):如果没有设置,则使用CAS竞争锁;如果设置,则尝试使用CAS将偏向锁指向当前线程(替换ThreadID)。
偏向锁的获取过程
1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
2. 如果为可偏向状态,则测试对象头中的线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)(在这个时间点上没有字节码正在执行)时获得偏向锁的线程被挂起。如果原线程已退出同步块,偏向锁降级为无锁状态。否则,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
5. 执行同步代码。

偏向锁的撤销
偏向锁撤销是指在获取偏向锁的过程中因不满足条件导致要将锁对象改为非偏向锁状态,而偏向锁释放是指退出同步块时的过程。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
倘若线程退出同步代码块时,并不会由偏向锁变为无锁状态,依然保存当前线程的ID。
6.2 轻量级锁
引入轻量级锁的主要目的是在多线程竞争不激烈的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。偏向锁运行在只有一个线程进入同步块的后,当第二个线程加入锁竞争时,偏向锁就会升级为轻量级锁。
轻量级锁的加锁过程
1、线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word。这时候线程堆栈与对象头的状态如图:

2、拷贝对象头中的Mark Word到锁记录中;
3、拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
4、如果更新成功,那么该线程就拥有了该对象的锁,并且锁对象Mark Word的锁标志为设置为“00”,即表示此对象处于轻量级锁状态。这时候线程堆栈与对象头的状态如图:

5、如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量级锁的释放过程
轻量级解锁时,会使用CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有锁竞争。如果失败,锁就会膨胀成重量级锁。
例如:
当获取到锁的线程执行同步体之内的代码的时候,另一个线程也完成了上面创建锁记录空间,将对象头中的MarkWord复制到自己的锁记录中,尝试用CAS将对象头中的Mark Word修改为指向自己的锁记录的指针,但是由于之前获取到锁的线程已经将Mark Word中的记录修改过了(并且现在还在执行同步体中的代码),与这个现在试图将Mark Word替换为自己的锁记录的线程自己的锁记录中的Mark Word的值不符,CAS操作失败,因此这个线程就会不停地循环使用CAS操作试图将MarkWord替换为自己的记录。
这个循环是有次数限制的,如果在循环结束之前CAS操作成功,那么该线程就可以成功获取到锁,如果循环结束之后依然获取不到锁,则锁获取失败,MarkWord中的记录会被修改为指向重量级锁的指针,然后这个获取锁失败的线程就会被挂起,阻塞了。
当持有锁的那个线程执行完同步体之后想用CAS操作将MarkWord中的记录改回它自己的栈中最开始复制的记录的时候会发现MarkWord已被修改为指向重量级锁的指针,因此CAS操作失败,该线程会释放锁并唤起阻塞等待的线程,开始新一轮夺锁之争,而此时,轻量级锁已经膨胀为重量级锁,所有竞争失败的线程都会阻塞,而不是自旋。
轻量级锁一旦膨胀为重量级锁,则不可逆转。因为轻量级锁状态下,自旋是会消耗cpu的,但是锁一旦膨胀,说明竞争激烈,大量线程都做无谓的自旋对cpu是一个极大的浪费。
锁的优缺点的对比

7. 锁如何保证可见性
我们知道,如果一个变量被修改后,并不是立即就被刷新到主内存中。这便导致如果我们修改后,其他线程并不能看到我们修改的见过,这就是不可见。
为了解决该问题,当线程获取锁时,JMM会把线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。当线程释放锁时,JMM会把线程对应的本地内存中的共享变量刷新到主内存中。
本文详细阐述了Java中synchronized关键字的原理与应用,包括其实现机制、锁优化策略(如自旋锁、锁消除)、锁升级流程(从偏向锁到轻量级锁再到重量级锁)以及锁如何保证内存可见性。
4226

被折叠的 条评论
为什么被折叠?



