0%

jvm07_synchronized关键字和锁优化

synchronized

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。

庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

synchronized关键字最主要的三种使用方式

修饰实例方法

作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

修饰静态方法

就是给当前类加锁,获取的是当前类对象的锁。

所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象

因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

因为是类锁,同一个类的两个静态synchronized方法,是会存在锁冲突的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class A {  

public static synchronized void a() {
try {
System.out.println("a in");

TimeUnit.MINUTES.sleep(5L);

} catch (Exception e) {
//
}
}

public static synchronized void b() {
try {
System.out.println("b in");

TimeUnit.MINUTES.sleep(5L);

} catch (Exception e) {
//
}
}

public static void main(String[] args) {
Thread a = new Thread(() -> A.a());
Thread b = new Thread(() -> A.b());
a.start();
b.start();
}
}

给对象加锁,修饰代码块

指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

synchronized 关键字的底层原理

synchronized 同步语句块

1
2
3
4
5
6
7
8
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class

synchronized关键字原理

从上面我们可以看出:

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰方法

1
2
3
4
5
6
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

monitorenter、monitorexit、ACC_SYNCHRONIZED

monitorenter

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,如下:
每个对象维护着一个记录着被锁次数的计数器, 对象未被锁定时,该计数器为0。线程进入monitor(执行monitorenter指令)时,会把计数器设置为1.
当同一个线程再次获得该对象的锁的时候,计数器再次自增.
当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功。

monitorexit

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.

谷歌翻译一下,如下:

monitor的拥有者线程才能执行 monitorexit指令。
线程执行monitorexit指令,就会让monitor的计数器减一。如果计数器为0,表明该线程不再拥有monitor。其他线程就允许尝试去获得该monitor了。

ACC_SYNCHRONIZED

ACC_SYNCHRONIZED介绍

Method-level synchronization is performed implicitly, as part of method invocation and return. A synchronized method is distinguished in the run-time constant pool’s method_info structure by the ACC_SYNCHRONIZED flag, which is checked by the method invocation instructions. When invoking a method for which ACC_SYNCHRONIZED is set, the executing thread enters a monitor, invokes the method itself, and exits the monitor whether the method invocation completes normally or abruptly. During the time the executing thread owns the monitor, no other thread may enter it. If an exception is thrown during invocation of the synchronized method and the synchronized method does not handle the exception, the monitor for the method is automatically exited before the exception is rethrown out of the synchronized method.

谷歌翻译一下,如下:

方法级别的同步是隐式的,作为方法调用的一部分。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。
当调用一个设置了ACC_SYNCHRONIZED标志的方法,执行线程需要先获得monitor锁,然后开始执行方法,方法执行之后再释放monitor锁,当方法不管是正常return还是抛出异常都会释放对应的monitor锁。
在这期间,如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。
如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

小结

java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来实现的。

方法级别的同步是隐式的,无需通过字节码指令来控制。虚拟机可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否被声明为同步方法。

java虚拟机的指令集中有monitorenter、monitorexit两条指令来支持 synchronized 关键词的语义。正确实现 synchronized 关键字需要javac编译器和java虚拟机两者共同协作支持。

monitor监视器

montor到底是什么呢?

它可以理解为一种同步工具,或者说是同步机制,它通常被描述成一个对象。

操作系统的管程是概念原理,ObjectMonitor是它的原理实现。

操作系统的管程

  • 管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
  • 这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。
  • 与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。
  • 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

ObjectMonitor-JVM的实现

在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的。

工作机理

  • 要获取monitor的线程,首先会进入_EntryList队列。
  • 当某个线程获取到对象的monitor后,进入_Owner区域,设置为当前线程,同时计数器_count加1。
  • 如果线程调用了wait()方法,则会进入_WaitSet队列。它会释放monitor锁,即将_owner赋值为null, _count自减1,进入_WaitSet队列阻塞等待。
  • 如果其他线程调用 notify() / notifyAll() ,会唤醒_WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入_Owner区域。
  • 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。

对象与monitor关联

  • 对象里有对象头
  • 对象头里面有Mark Word
  • Mark Word指针指向了monitor

锁优化

对象头

锁升级流程

偏向锁 -> 轻量级锁 -> 重量级锁

偏向锁(加锁/解锁过程)

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁

偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

进入了偏向锁模式后,原来的hashCode怎么办呢?

  1. 当一个对象计算过一次一致性哈希码后,他就无法再进入偏向锁状态了。
  2. 当一个对象处于偏向锁状态,有收到一个需要计算一致性哈希码的请求,它会立刻撤销偏向状态并膨胀为重量级锁。

轻量级锁(加锁/解锁过程)

JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

加锁过程

  1. 代码进入同步块的时候,如果此时同步的对象没有被锁定(锁标志位为01状态),虚拟机首先将在当前线程的帧栈中简历一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝(即 Displaced Mark Word)。
  2. 然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新操作成功了,那么这个线程将获得该对象的锁,并且将对象Mark Word的锁标志位变为00。
  3. 如果更新失败了,虚拟机会先检查Mark Word是否指向当前线程的帧栈(可重入逻辑),如果不,说明锁对象已经被其他线程抢占了,轻量级锁不在有效,将会升级成重量级锁,Mark Word中的锁标志变为10,后面等待锁的线程也进入阻塞状态。

解锁过程

  1. 如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word交换回来。替换成功,正常解锁。
  2. 替换失败,说明有其他线程尝试获取过该锁,此时应是重量级锁了,释放锁的同时,唤醒被挂起的线程。

自旋锁和自适应自旋

自旋锁:为了让线程等待,我们只需要让线程执行一个忙循环(自旋)。
缺点:虽然避免了线程切换的开销,但是需要占用处理器时间,如果锁被占用时间很短,自旋效果很好,相反如果锁被占用的时间很长,自旋的线程会带来性能上的浪费。

自适应自旋:自旋的时间不在固定,由前一次在同一个锁上的自旋时间及锁的拥有者状态决定。如果在同一个对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,虚拟机会认为这次自旋也很有可能获取到锁,会自旋一定次数;如果一个锁很少获取成功,线程会被直接挂起,省略掉自旋过程。

锁消除

虚拟机及时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

1
2
3
4
5
6
7
public String concatString(String s1,String s2,String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

经过逃逸分析后,sb的所有引用都不会刀一刀concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全的消除掉。在解释执行时这里仍然会加锁,但是经过服务器编译器的即时编译之后,这段代码会忽略掉所有的同步措施。

锁粗化

我们编写代码的时候,如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即是没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。虚拟机会将锁扩展到整个代码段,这就是锁粗化。

参考文章