Java多线程之synchronized及其优化

时间:2021-07-05 04:42:20

Synchronized和同步阻塞
synchronized是jvm提供的同步和锁机制,与之对应的是jdk层面的J.U.C提供的基于AbstractQueuedSynchronizer的并发组件。synchronized提供的是互斥同步,互斥同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只有一个线程访问。
在jvm中,被synchronized修饰的代码块经javac编译之后,会在代码块前后分别生成一条monitorenter和moniterexit字节码指令,这两个字节码都需要一个reference类型的参数来指定要锁定和解锁的对象。如果synchronized指定了对象参数,reference就是该对象的引用,如果没有手动指定,那就根据synchronized修饰的是实例方法还是类方法,取对应的对象实例或Class对象来作为锁对象。
值得一提的是,java中Object类有两个方法wait()和notify()(notifyAll()与notify()类似)和同步锁相关。至于这两个方法为什么要放在Object类中,原因如下:wait()方法的语义是使当前线程(调用wait()方法的线程)等待,知道被notify()方法唤醒。当前线程必须拥有对象的锁,调用wait()方法后,当前线程会释放同步对象的锁。因此,wait()方法必须在synchronized修饰的代码块内(肯定获取了同步锁),由于任何Java对象都能作为对象锁,因此这两个方法需要放在Object中。
Java线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要进入到内核完成,这种从用户态转换到内核态的状态转换需要耗费很多的处理器时间,所以synchronized是Java中非常消耗资源的一种操作。在JDK1.5之前,synchronized与基于JDK实现的ReentrantLock相比,性能要低很多。在JDK1.6及之后的版本中,synchronized实现了很多针对于锁的优化措施,这些优化有很大一部分与ReentrantLock的实现思路相似。

非阻塞同步与CAS
阻塞同步经常涉及到加锁和解锁,这就意味着用户态和内核态的切换,非常消耗资源。为此,一种基于冲突检测的乐观并发策略应运而生,这种策略的核心思想是:先对数据进行操作,如果没有其它线程争用数据,那就认为操作成功;否则,采取其它方式来保证数据操作成功(例如:一直重试,知道成功)。这种并发策略不需要把线程挂起,所以称为非阻塞同步。
CAS是CompareAndSwap的简称,它需要三个操作数,分别是内存位置、预期值和更新值。当CAS指令执行时,处理器先判断内存位置的值与预期值是否相等,如果相等,则将预期值更新成更新值;否则,认为有其它线程已经修改过这个内存位置的值了,更新操作不会允许。不论更新操作是否发生,CAS操作都会返回预期值,CAS操作是一个原子操作。
CAS非常适用于非阻塞同步,例如:要将一个int型的值i加1,使用CAS的思路是先判断变量i的内存位置的值是否有修改,如果没有,则将这个位置的值更新成i+1;否则,认为其它线程已经修改过这个值了,可以再次调用前面的操作尝试更新,直到更新成功。因为,在判断内存位置的值时需要先获取这个内存位置的值,在Java中,为了保证这个变量的可见性,需要用volatile关键字进行修饰。在JDK1.5后,Java程序提供了sun.misc.Unsafe类实现了基本类型的CAS操作的封装,JUC组件就是基于这个类实现的。以java.util.concurrent.atomic.AtomicInteger类为例,这个类包装了一个实例变量value:

private volatile int value;
1
对这个变量实现自增的源代码如下:

/**
* Atomically increments by one the current value.
*
* @return the updated value
*/
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
1
2
3
4
5
6
7
8
其中Unsafe#getAndAddInt()的实现如下:

public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}
1
2
3
4
5
6
7
8
这里var2就是内存地址的值,var5是预期值,当compareAndSwapInt()方法返回true时,表示这个CAS操作成功,否则重试,知道成功为止。从这里,可以发现CAS操作的三个缺点:

ABA问题,就是变量的值如果被其它线程更改了但是在当前线程调用CAS指令之前又被更新成了预期值,CAS指令仍然会认为内位置的值没被修改过,这与CAS设计的初衷是不符的。不过,可以通过引入版本号解决这个问题;
效率问题。如果存在大量的线程修改指定变量的值,则CAS操作成功的几率很低,这样一直重试会降低虚拟机的性能;
CAS仍然只能做到对单个变量同步,无法实现像synchronized那样对一段代码同步。
这里,unsafe是Unsafe的实例,这个类不是提供给用户程序使用的类,它里面封装的大都是一些JNI方法,程序员一般不需要使用这个类,只需要知道它是Java中CAS操作的封装即可。
锁优化
前面描述的synchronized对应的同步锁比较重量级,为此,JDK1.5之后,开发人员实现了各种锁优化技术,包括自适应自旋锁、锁消、锁粗化、轻量级锁和偏向锁等。注意,这些锁都是虚拟机层面的优化,可以认为是对synchronized对应的字节码的优化。

原哥博客 http://www.yuange.tech/

自旋锁和自适应自旋锁
自旋锁是虚拟机开发人员的对共享数据的统计分析得到的一种优化技术,就是,大多数情况下,共享数据的锁定状态只会持续很短的一段时间,其它线程在等待锁的时候,为了这段等待时间而挂起和恢复线程并不值得。为了让线程等待一小会时间而不挂起,可以让线程执行一个忙循环(自旋),这就是自旋锁。注意,自旋锁是需要消耗CPU资源的,如果,碰巧共享数据的锁定时间很长,那么,自旋锁的性能反而会下降。自适应自旋锁是自旋锁的一种优化,它可以动态调整自旋时间,避免盲目等待。

锁消除
锁消除是javac层面上的优化。对一些程序员编写的用或调用synchronized修饰的代码,但是被检测到不可能存在共享数据竞争的情况下,javac会对这部分代码进行优化,消除多余的同步指令。

锁粗化
原则上,编写程序时,要求同步块越小越好,但是,当一系列的连续操作都是对同一个对象的反复加锁和解锁时,就是对资源的浪费,这时,将锁的范围扩大到整个操作序列上有利于节省反复加锁和解锁占用的资源。锁消除和锁粗化技术都是javac或jvm的智能优化。

轻量级锁
轻量级锁是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁实现的依据是:“绝大部分时候,在整个同步周期内是不存在竞争的”,当不存在竞争时,使用轻量级锁不需要使用操作系统的互斥量,这样能节省资源。但是,当存在竞争时,轻量级锁将既存在CAS开销,还存在传统重量级锁操作的开销,性能更低。
轻量级锁的实现分为加锁和解锁两个过程:

加锁:jvm在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,存储锁对象的对象头信息。存储成功后,虚拟机将使用CAS操作尝试将要锁定的对象的对象头更新为指向锁记录的指针,如果这个操作成功了,那么就认为当前线程拥有了该对象的锁,此时,对象头的锁标志位会更新为“00”——轻量级锁;如果失败了,则有两种情况:这个线程已经拥有了这个对象的锁或者共享数据存在竞争,判断是否已经拥有这个锁只需要检查目标对象的Mark Word是否指向当前栈帧,如果是,则直接进入同步快;否则,认为存在竞争,将轻量级锁更新为重量级锁,之后就与传统的重量级锁操作一样了;
解锁:解锁也是基于CAS操作实现的。在解锁时,如果对象的Mark Word仍指向当前线程栈帧,用CAS操作吧对象的Mark Word更新回来;如果成功,整个同步过程完成;否则,说明有其它线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
偏向锁
偏向锁相对于轻量级锁是一种更加激进的做法:在无竞争的情况下把整个同步都消除掉,偏向锁的意思是锁会偏向于第一个获取它的线程,如果在接下来的执行过程中,该锁没有被其它线程获取,那么持有偏向锁的线程将永远不需要同步。虚拟机通过-XX:+UseBiasedLocking参数启动偏向锁,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”——偏向锁模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作;当有另一个线程尝试获取这个锁时,偏向锁就结束。这时,通过锁对象目前是否处于锁定状态,撤销偏向后恢复到未锁定或轻量级锁定的状态,后续的同步操作就如同前面介绍的轻量级锁那样执行。