在并发编程领域,有两大核心问题:一个是互斥,另一个是同步
管程技术是一把解决并发问题的万能钥匙,Java语言在1.5之前,提供的唯一并发原语就是管程并且在1.5之后提供的SDK并发包,JUC也是以管程技术为基础的

什么是管程

Java在1.5之前仅仅提供了synchronized关键字, wait()notify()notifyAll() 三个方法。在操作系统中,我们知道使用信号量能够解决所有并发问题,但是在Java中使用的是管程。synchronized关键词以及三个方法都是管程的组成部分。
管程指的是管理共享变量以及对共享变量的操作过程,让这些操作支持并发。在Java语言中,也就是管理类的成员变量和成员方法,让他们是线程安全的。这就需要保证同一时间,只有一个线程在管程中。

管程可以看作是和信号量等价的,能用管程实现信号量,也能用信号量实现管程。管程更加简单

image.png

管程模型

管程历史上有三种不同的管程模型,分别是Hasen模型、Hoare模型以及MESA模型。MESA模型广泛使用,Java管程的实现参考的也是MESA模型

MESA模型

image.png

使用入口等待队列保证了线程之间的互斥,使用条件变量的等待队列保证了线程之间的同步(线程操作共享变量之前需要检查是否满足条件)。条件变量可以有多个。

把找医生看病的流程用来举例子:

  • 当医生还在给别人看病的时候,你只能够在门外等待,也就是在入口等待队列等待。
  • 当等到你的时候,就进入管程中,这个时候判断条件变量是否满足,就像医生发现你还没抽血,就让你出门抽血去了。也就是进入条件变量等待队列,然后其他线程也能进入管程了
  • 当你抽血完成,满足条件后,继续在条件变量等待队列中等待,因为其他线程还在管程中,当有一个线程从线程中退出的时候,就会通知在条件变量中满足条件的线程,然后又重新在入口队列中等待。
image.png
  1. 多个线程进入管程的入口队列e,并试图获取临界区锁。获取到锁的线程进入临界区,其他线程仍然在e中。
  2. 通过外部条件来判断进入临界区的线程是否能执行操作,分为以下3、4两种情况。
  3. 如果不能执行,则调用wait原语,该线程阻塞,释放临界区的锁,离开临界区并根据条件进入a.q或者b.q。
  4. 如果能执行,那么在执行完毕后调用notify原语(相当于signal),唤醒a.q或b.q中的一个线程。执行完毕的线程释放锁,并离开管程的作用域。
  5. 被唤醒的线程进入队列e,返回第1步重新开始。

下面以一段代码,结合await()和signal()来解释

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 线程安全的阻塞队列
public class BlockedQueue<T>{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();

// 入队
void enq(T x) {
lock.lock();
try {
// 如果队列是满的,就需要进入等待队列不满的条件队列
while (队列已满){
// 等待队列不满,wait让出锁
notFull.await();
}
// 入队操作

// 入队后,通知等待队列不空的条件队列
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
// 如果队列是空的,需要进入等待队列不空的条件队列
while (队列已空){
// 等待队列不空,让出锁
notEmpty.await();
}
// 出队操作

// 出队后,通知对待队列不满的条件队列
notFull.signal();
}finally {
lock.unlock();
}
}
}

注意,上面的while循环调用wait,是MESA管程特有的

模型对比

管程要求同一时刻只允许一个线程执行,当线程T2的操作,使得T1等待的条件满足后,T1和T2执行的顺序在不同的管程模型中不同

Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。

Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。

MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量

MESA管程相对于其他两个模型更加公平,Hoare会让先唤醒的限制性,立刻切换上下文

使用notify()

如果没有深思熟虑,尽量使用notifyAll(),notify()使用的条件比较苛刻

  • 所有等待线程拥有相同的等待条件
  • 所有等待线程被唤醒后,执行相同的操作
  • 只需要唤醒一个线程

上面的例子中,对于notFull条件变量,所有的等待条件都是wait not full,唤醒后执行的操作也都相同

Java Synchronized中的管程

Java中内置的管程synchronized对于MESA模型进行了精简,只有一个条件变量‘

image.png


synchronized关键字修饰的代码块,在编译时候会自动生成相关加锁和解锁的代码,但是仅仅支持一个条件变量,JUC并发包中实现的管程支持多个条件变量,但是需要开发人员自己进行加锁解锁操作

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class SynchronizedExample {
private final Object lockObj = new Object();
private int data;
private volatile boolean isAvailable = false;

public void method1() {
synchronized (this) {
System.out.println("Synchronized block w/ this");
}
}

public void method2() {
synchronized (lockObj) {
System.out.println("Synchronized block w/ lock object");
}
}

public static synchronized void method3() {
System.out.println("Synchronized static method");
}

public synchronized int get() {
try {
while (!isAvailable) {
wait();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
isAvailable = false;
notifyAll();
return data;
}

public synchronized void put(int data) {
try {
while (isAvailable) {
wait();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
this.data = data;
isAvailable = true;
notifyAll();
}
}

上述的代码给出了synchronized的四种用法,代码块锁this对象,代码块锁其他对象,代码块锁Class,static方法锁Class,非static方法锁this
synchronized其实也就锁了两个东西,Class和Object,锁Class根本还是锁的是这个类加载的时候默认创建的一个Object

Syncronized怎么锁

对于下面的代码,执行 javac SynchronizedTst.java 然后 javap -v SynchronizedTst.class

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
public class SynchronizedTst {

// 作用于类级别
public synchronized static void testClass() {
System.out.println("synchronized testClass!!!");
}

// 作用于方法级别
public synchronized void testMethod() {
System.out.println("synchronized testMethod!!!");
}

// 作用于代码块级别
public void testBlock() {
synchronized (this) {
System.out.println("synchronized testBlock!!!");
}
}
// 作用于代码块级别
public void testBlockClass() {
synchronized (SynchronizedTst.class) {
System.out.println("synchronized testBlockClass!!!");
}
}
}

得到反编译的代码

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
...
public class concurrency.SynchronizedTst
...
{
public concurrency.SynchronizedTst();
...

public static synchronized void testClass();
descriptor: ()V
flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=0, args_size=0
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String synchronized testClass!!!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 11: 0
line 12: 8

public synchronized void testMethod();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #21 // String synchronized testMethod!!!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 16: 0
line 17: 8

public void testBlock();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #23 // String synchronized testBlock!!!
9: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
...
public void testBlockClass();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #25 // class concurrency/SynchronizedTst
2: dup
3: astore_1
4: monitorenter
5: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #27 // String synchronized testBlockClass!!!
10: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
...
}

可以看出,
对于代码块反编译后,输出的字节码有monitorenter以及monitorexit语句,无论是锁对象还是锁类
对于非静态方法反编译后,输出的字节码有ACC_SYNCHRONIZED标志
对于静态方法反编译后,输出的字节码有ACC_SYNCHRONIZED和ACC_STATIC标志
所以在字节码层面:

  1. synchronized 的作用域不同,JVM 底层实现原理也不同
  2. synchronized 代码块是通过 monitorenter 和 monitorexit 来实现其语义的
  3. synchronized 方法是通过 ACC_SYNCRHONIZED 来实现其语义的,当线程执行有ACC_SYNCRHONIZED标志的方法,需要获得monitor锁

monitorenter和monitorexit

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

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_SYNCRHONIZED

ACC_SYNCRHONIZED

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 methodinfo structure by the ACCSYNCHRONIZED 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锁。
在这期间,如果其他线程来请求执行方法,会因为无法获得管程锁而被阻断住。
如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

ObjectMonitor

在Java虚拟机中,Monitor是由ObjectMonitor实现的,依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步,使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行
主要结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ObjectMonitor() {
_header = NULL;
_count = 0; // 线程获取管程锁的次数;
_waiters = 0, // 处于等待状态的线程数;
_recursions = 0; // 管程锁的重入次数;
_object = NULL;
_owner = NULL; // 持有该ObjectMonitor的线程的指针;
_WaitSet = NULL; // 处于等待状态的线程队列(双向链表);
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 线程竞争管程锁时的队列(单向链表)。
FreeNext = NULL ;
_EntryList = NULL ; // 管程的入口线程队列(双向链表);
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
image.png
  1. 想要获取monitor的线程,首先会进入_EntryList队列。
  2. 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
  3. 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
  4. 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
  5. 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。

线程执行时如何与锁住的对象或者类的monitor关联

HotSpot堆中的对象实例由对象头、实例数据和对其填充3部分组成

  1. 对象头:Mark Word和Class Point
  2. 实例数据:对象的属性数据,父类的属性数据
  3. 对齐填充,保证对象起始地址是8字节的整数倍

对象头

对象头分为Mark Word以及类元数据指针,MarkWord非固定,存储对象自身的运行时数据,如下表
不同标识位(不同状态),存储的是不同的

image.png

每个对象都有一个monitor与之关联,对象与monitor可以一起销毁,或者在线程试图获取对象锁的时候自动生成,但是当一个monitor被线程持有后,就处于锁定状态。
synchronized是重量级锁,当对象被锁住的时候,MarkWord锁标志位10,指针指向的就是ObjectMonitor的地址。

锁住类而不是锁住对象的时候,monitor的指针在哪里呢?
在Java中,每个类都有一个Class对象,当虚拟机在类加载过程中,会自动创建一个Class对象来表示该类,该类的每个实例对象都包含一个指向该Class对象的引用,可以通过getClass()来访问。
所以锁住类的时候,monitor的指针就存在这个类的Class对象里

锁升级

在JDK1.6之前,使用synchronized就意味着使用重量级锁,即直接调用 ObjectSynchronizer::enter() 方法。每个锁都有一个monitor对象,当有很多线程同时访问的时候,线程的阻塞和唤醒需要CPU切换上下文,而锁状态一般持续时间很短,导致性能下降。
从JDK1.6开始,synchronized实现中引入偏向锁、轻量级锁、适应性自旋锁、锁消除等大量优化。

image.png

无锁

所有线程都能够访问并修改同一个资源,但同时只有一个线程能修改成功
无锁让修改操作在循环内进行,不断的尝试修改,也就是CAS,如果有多个线程修改同一个值,必定有一个线程能够修改成功,修改失败的线程也会重试,直到成功。
在这个状态下,线程都快乐运行

偏向锁

Java15废弃了偏向锁,因为撤销很复杂

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

线程先判断是否为可偏向状态,然后判断Mark Word是否是当前线程的ID

  • 如果不是,就用CAS在对象头的MarkWord中存储自己的线程ID
    • 失败了,走撤销流程,当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁
    • 成功了就继续
  • 如果是,就直接进入临界区
  • 如果是空的,就CAS修改,冲突了还是得走撤销流程

这样线程进入、离开临界区的时候不用通过CAS操作来加锁和解锁,而是判断MarkWord中是否存储当前线程的偏向锁
这样减少了轻量级锁中多次CAS指令的消耗,只需要在置换ThreadID的时候用CAS换

所以说偏向锁和无锁的标志位都是01,因为都没有锁,偏向锁靠字段biased_lock来区分,1代表使用

当遇到其他线程尝试竞争偏向锁的时候,持有偏向锁的线程就会释放,并且线程不会主动释放,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

安全点会导致stw,导致性能下降,这种情况下应当禁用。所以一般JVM并不是一开始就开启偏向锁的,而是有一定的延迟,这也就是为什么会有无锁态的原因

关闭偏向锁的启动延迟 -XX:BiasedLockingStartupDelay=0

关闭偏向锁 -XX:-UseBiasedLocking=false

轻量级锁

monitorenter从无锁到加锁的时候,会根据是否启用偏向锁(UseBiasedLocking)来决定是使用偏向锁(调用 ObjectSynchronizer::fast_enter() 方法)还是轻量级锁(调用 ObjectSynchronizer::slow_enter() 方法)

当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,试图抢占的线程一开始会使用自旋+CAS来获取锁,不阻塞从而提高性能。

加锁过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
//取出Mark Word
markOop mark = obj->mark();
//走到此说明已经不是偏向锁了
assert(!mark->has_bias_pattern(), "should not see bias pattern here");

if (mark->is_neutral()) {
//如果是无锁状态
//将Mark Word拷贝到Lock Record的_displaced_header 字段里
lock->set_displaced_header(mark);
//CAS修改Mark Word使之指向Lock Record
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
TEVENT (slow_enter: release stacklock) ;
return ;
}
} else
if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
//mark->has_locker() -->表示已经是轻量级锁
//THREAD->is_lock_owned((address)mark->locker()) 并且是当前线程获取了轻量级锁
//这两点说明当前线程重入该锁
//直接设置header==null
lock->set_displaced_header(NULL);
return;
}

在代码进入临界区的时候

如果是无锁状态,就会在线程栈中创建一个Lock Record的空间,然后把对象头中的Mark Word复制到Lock Record中的_displaced_header字段。用以存储无锁状态的Mark Word,待释放锁时恢复

拷贝成功后,使用CAS更新对象的Mark Word,将其更新为Lock Record的指针,并且Lock Record里面的obj指针指向对象头。表示该Lock Record所在的线程获取了轻量级锁

如果当前是轻量级锁状态并且是当前线程,原先header中的displaced_header置空

image.png

更新成功后,线程就拥有了该对象的锁,并且更新锁标志位为00,进入临界区
更新失败了,则说明有其他线程比他替换得更快,就只有开始自旋去获取锁了

如果当前只有一个等待线程,那么等待线程就自旋获取锁,因为临界区的时间通常很短,就不用阻塞线程进行上下文切换。但是自旋是一直消耗CPU的,所以当自旋超过一定次数,或者有线程在持有锁,有线程在自旋,又来了新的线程,就升级为重量级锁

重量级锁

如果另外的线程尝试获取锁的时候,轻量锁正被其他线程占有,自旋多次失败后,就会修改markword,锁状态升级,表示该进入重量锁
轻量级锁释放的时候,使用CAS,如果发现当前的markword和之前拷贝在lock record中的不一样,就切换到重量级锁

锁的状态变为10,MarkWord存储的是指向重量级锁的指针,也就是ObjectMonitor的指针,等待锁的线程都会阻塞

image.png

综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

锁自旋

为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功

自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX: PreBlockSpin来更改),就需要采取一定措施

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,如果之前成功了,这次自旋将会有更长的时间,如果之前都很少成功,这次将忽略自旋,直接阻塞

可重入

synchronized是可重入锁

  • 偏向锁:检查markWord中的线程ID是否是当前线程,如果是的话就获取锁,继续执行代码;
  • 轻量级锁:检查markWord中指向lockRecord的指针是否是指向当前线程的lockRecord,是的话继续执行代码;
  • 重量级锁:检查_owner属性,如果该属性指向了本线程,_count属性+1,并继续执行代码。

锁粗化

避免连续的加锁解锁操作,直接把锁连接在一起,拓展成一个范围更大的锁

锁消除

是jvm检测到这段程序里不存在共享数据竞争问题,也就是变量没有逃逸出方法外,这个时候jvm就会把这个锁消除掉

Reference

http://gk.link/a/121Hg
https://juejin.cn/post/7136205392337436686
https://www.jianshu.com/p/e624460c645c
https://juejin.cn/post/7007656138518822925
https://blog.csdn.net/weixin_40910372/article/details/107726978
[https://tech.meituan.com/2018/11/15/java-lock.html](