技术文章

程序员面试常见问题:synchronized(下)

3. synchronized原理

 

为了研究synchronized的原理,我们就需要对使用这个关键字的java文件编译之后生成的class文件进行反编译,查看下java字节码对应的机器指令是怎么样的。

 

Java代码是这样的:

 

程序员面试

 

通过jdk自带的javap工具对SyncTest.class文件进行反编译获取字节码指令,执行命令“javap -v SyncTest”,然后获取到反编译的结果如图所示:

 

程序员面试

同步代码块

 

程序员面试

同步方法

 

我们可以看到使用同步代码块的test 方法中看到两个熟悉的指令monitorenter、monitorexit,即遇到synchronized的时候执行monitorenter指令获取到锁,而当方法运行结束时执行monitorexit指令释放锁。其他指令有兴趣的话可以百度“JVM虚拟机字节码指令表”查看具体含义。

 

monitorenter和monitorexit通过官方介绍的这两个指令进行翻译之后的大体上是这样的:

 

“对象都有一个监视器锁(monitor)关联,当且仅当拥有所有者时,monitor才会被锁定,并且会有一个计数器记录着锁的次数,如果未获取到monitor锁那么计数为0。执行到monitorenter指令的线程,会尝试去获得对应的monitor锁,如果获取成功则计数加1,当同一个线程再次获得该对象的锁的时候,计数器再次+1,当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功。线程执行monitorexit指令,就会让monitor的计数器-1。如果计数器为0,表明该线程不再拥有monitor锁。其他线程就允许尝试去获得该monitor锁”

 

monitorenter和monitorexit的执行流程图如下:

 

程序员面试

 

而在同步方法test2的反编译字节码中并没有看到monitorenter和monitorexit两个指令,但是发现图中红色框中标记了一个flags值为ACC_SYNCHRONIZED。ACC_SYNCHRONIZED介绍如下:

 

“方法级同步是隐式执行的,作为方法调用和返回的一部分。同步方法在运行时常量池的methodinfo结构中通过ACCSYNCHRONIZED标志进行区分,该标志由方法调用指令检查。当调用为其设置了ACC_SYNCHRONIZED的方法时,执行线程进入monitor监视器,调用方法本身,然后退出监视器,不管方法调用是正常完成还是突然完成。在执行线程拥有监视器期间,其他线程不能进入监视器。如果在调用synchronized方法的过程中抛出异常,并且synchronized方法不处理该异常,则在将异常从synchronized方法中重新抛出之前,该方法的监视器将自动退出”

 

通过上面的描述我们知道了同步方法通过标志值为ACC_SYNCHRONIZED也可以获取到monitor锁,并在方法结束的时候会释放monitor锁,从而也达到了同步的效果。

 

4. JDK1.6对synchronized的优化

 

上述介绍了synchronized的使用和原理,我们发现虽然synchronized锁实现了并发安全,但是它有点“重”,因为当一个线程访问同步方法或者代码块获取锁了之后,其他的线程都处于等待阻塞状态,浪费CPU的资源,并且频繁的获取和释放锁也消耗CPU的性能等等,所以以前一提到synchronized大家都说它是一个重量级锁。但是到JDK1.6的时候就对synchronized进行了各种优化来提高它的效率,如JVM会对java代码进行锁粗化、锁消除处理,适应性自旋解决自旋占用大量CPU资源问题,并且加入了、偏向锁和轻量级锁等对锁进行了升级优化,最后才是重量级锁。

 

l 锁粗化

 

加锁的共享资源范围越小,那么其他线程等待阻塞的时间就会越短,这样明显比对大范围资源加锁效率高。但是加锁和释放锁也需要时间和消耗资源的,如果出现频繁的加锁和释放锁操作那么就会导致消耗CPU性能,锁粗化就是解决这种问题的。锁粗化就是在出现很小范围内代码进行连续加锁释放锁操作的时候,对其锁的范围进行扩大,这样锁就变成了外部的一个,避免了小范围频繁的锁操作。典型的案例就是for循环,如下:

 

程序员面试

 

JVM锁粗化处理后:

 

程序员面试

 

l 锁消除

 

锁消除是指当java进行JIT(Just-In-Time)编译(即时编译:程序运行时把Class文件字节码编译成本地机器码来提高执行效率)运行程序的时候,通过上下文进行逃逸分析(逃逸分析:如果变量被方法中使用,又被方法外使用,那么这个变量就发生了逃逸)发现如果变量发生了逃逸那么应该保持锁,如果没有发生逃逸那么不存在竞争资源的问题从而会把锁消除掉,案例如下:

 

程序员面试

 

我们知道StringBuffer是一个线程安全的类,它的append方法被synchronized修饰,但是此处因为sb变量只是一个局部变量,sb 的所有引用不会 “逃逸” 到 test方法之外其他线程无法访问控制到它,所以即使append方法操作有锁,JVM即使编译后就会把这个锁消除掉,上述代码就会忽略掉同步锁而执行。

 

l 悲观锁、乐观锁、CAS的概念

 

悲观锁:在使用synchronized的时候,如果一个线程获取到锁,那么它就非常的悲观,认为其他线程访问共享资源会出现冲突,所以其他线程会被阻塞。

 

CAS操作:compare and swap意思是比较并交换,CAS操作中有三个参数:内存位置(V)、预期原值(A)和新值(B);如果内存位置的值与预期原值相匹配,那么会自动将该值更新为新值 ,如果不一样那么重新计算直到一直为止。

 

乐观锁:CAS的操作就属于乐观锁,不加锁,而是认为多线程访问共享资源不会出现冲突的情况,如果出现了冲突那么就重试,直到内存值和预期值不冲突为止。

 

l 锁升级

 

JDK1.6后synchronized的锁状态总共有4种:无锁—>偏向锁—>轻量级锁—>重量级锁,锁的升级顺序是从无锁到重量级的顺序,锁只能升级不能降级。

 

在说锁的升级原理之前呢,我们先了解下我们的对象,大部分对象都是存储在堆中,而对象的组成主要有三部分:对象头、实例数据、对齐填充;

 

  • 对象头

 

对象头由MarkWord 、指向类的指针、以及数组长度三部分组成,这里我们需要着重熟悉的就是MarkWord部分。MarkWord 用于存储对象的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。MarkWord 的内容变化会随着锁的升级而变化。具体变化如下表:

 

程序员面试

 

  • 实例数据

 

对象真正存储的有效信息,也就是代码中定义的各种字段内容。

 

  • 对齐填充

 

对齐填充没有特别的含义不是必然存在的,它仅仅起着占位符的作用。

 

无锁

 

当对象已创建存储在内存中,它对象头MarkWord锁标志默认就是无锁状态,无锁状态不存在资源的锁定。

 

偏向锁

 

很多时候可能一段同步代码总是被一个线程多次访问,这时候并不存在多线程竞争的问题,这时候就是加入偏向锁,使得该线程在后续访问中自动获取到锁,降低了频繁获取锁释放锁代码的资源消耗。

 

原理是当一个线程执行同步方法或者代码块的时候,首先从对象头中的MarkWord中获取是否是偏向锁标志:

 

(1)如果标志为0证明当前为无锁状态,就会将当前线程的ID添加到对象头的MarkWord中,然后将是否是偏向锁标志改为1,再执行同步代码;

(2)如果标志为1证明已经是偏向锁状态,那就从MarkWord中获取到偏向线程ID跟当前线程ID比较,如果一样则不需要再次获取锁直接执行同步代码;如果不一样执行CAS操作将MarkWord的线程ID设置为当前线程ID,设置成功则执行同步代码,如果CAS操作失败证明存在多线程竞争情况,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁。

 

轻量级锁

 

轻量级锁是指当前线程是偏向锁但是被其他线程访问的时候则升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

 

自旋:线程的阻塞和唤醒需要C P U从用户态和内核态进行转换,比较耗时,所以JVM在发现锁被一个线程占用的时候,并不会让其他线程阻塞而是一直循环检测锁是否被释放,当然自旋的有次数限制(可以通过JVM参数 -XX:PreBlockSpin 修改),如果达到次数还是没有获取锁才会被挂起。

 

升级为轻量级锁主要有两种情况,第一种就是我们说的当前线程的偏向锁被其他线程访问的时候会把当前线程升级为轻量级锁;另外一种就是关闭了偏向锁功能(JVM参数 -XX:-UseBiasedLocking )。

 

如果当前线程获取到的是轻量级锁,锁标志为00,如果还有一个线程访问的时候就会进行自旋,但是如果自旋超过了设定的自旋次数,这个线程还是会阻塞,或者在线程自旋的过程中又有其他线程访问了那么就会把轻量级锁升级为重量级锁。

 

重量级锁

 

当轻量级锁升级为重量级锁之后,锁的标志改为10,就会变成我们最初所说的现象,一个线程访问其他线程都阻塞,并且重量级锁底层依赖的是操作系统的互斥锁(Mutex Lock)实现的,线程的切换需要用户态和内核态的转换,比较耗时效率低。

 

最后我们使用张流程图简单的来总结下锁的升级过程:

程序员面试