解决线程原子性问题,最常见的手段就是加锁,Java提供了两种加锁的方式,一个synchronized隐式锁,另外一个是通过J.U.C框架提供的Lock显式加锁。本文主要介绍一个Synchronized的实现方式。
synchronized概述
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 翻译为中文的意思是同步,也称之为”同步锁“。
synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
synchronized的使用方式
基本语法
synchronized有两个作用范围:方法和局部代码块,代码示例如下
public class SynchronizedDemo {
private int v;
private static int a;
private final Object lock = new Object();
// 修饰非静态方法 对象锁
public synchronized void add(int value) {
v += value; // 临界区
}
public void sub(int value) {
// 修饰局部代码块 对象锁
synchronized (lock) {
v -= value; // 临界区
}
}
// 修饰静态方法 类锁
public static synchronized void multi(int value) {
a *= value; // 临界区
}
public static void div(int value) {
// 修饰局部代码块 类锁
synchronized (SynchronizedDemo.class) {
a /= value; // 临界区
}
}
}
复制代码
java编译器会在synchronized修饰的方法或代码块前后自动Lock,unlock。
- synchronized修饰代码块,锁定是个obj对象,或者是一个类,sychronized(this.class)
- synchronized修饰静态方法,锁定是当前类的class对象
- synchronized修饰非静态方法,锁定的是当前实例对象this。
synchronized底层实现原理
synchronized对应的字节码
使用javap -verbose SynchronizedDemo
查看字节码文件
public synchronized void add(int);
descriptor: (I)V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=2, args_size=2
0: aload_0
1: dup
2: getfield #4 // Field v:I
5: iload_1
6: iadd
7: putfield #4 // Field v:I
10: return
LineNumberTable:
line 10: 0
line 11: 10
public void sub(int);
descriptor: (I)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=4, args_size=2
0: aload_0
1: getfield #3 // Field lock:Ljava/lang/Object;
4: dup
5: astore_2
6: monitorenter
7: aload_0
8: dup
9: getfield #4 // Field v:I
12: iload_1
13: isub
14: putfield #4 // Field v:I
17: aload_2
18: monitorexit
19: goto 27
22: astore_3
23: aload_2
24: monitorexit
25: aload_3
26: athrow
27: return
Exception table:
from to target type
7 19 22 any
22 25 22 any
LineNumberTable:
line 14: 0
line 15: 7
line 16: 17
line 17: 27
public static synchronized void multi(int);
descriptor: (I)V
flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #5 // Field a:I
3: iload_0
4: imul
5: putstatic #5 // Field a:I
8: return
LineNumberTable:
line 20: 0
line 21: 8
public static void div(int);
descriptor: (I)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #6 // class com/shawn/study/deep/in/java/concurrency/thread/SynchronizedDemo
2: dup
3: astore_1
4: monitorenter
5: getstatic #5 // Field a:I
8: iload_0
9: idiv
10: putstatic #5 // Field a:I
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
LineNumberTable:
line 24: 0
line 25: 5
line 26: 13
line 27: 23
复制代码
从上述展示的字节码可以看出:
add()函数对应的字节码如下所示。实际上,编译器只不过是在函数的flags中添加了ACC_SYNCHRONIZED标记而已,其他部分跟没有添加synchronized的add()函数的字节码相同。
add()函数对应的字节码如下所示。字节码通过monitorenter和monitorexit来标记synchronized的作用范围。除此之外,对于以下字节码,我们有点需要解释。其一,以下字节码中有两个monitorexit,添加第二个monitorexit的目的是为了在代码抛出异常时仍然能解锁。其二,前面讲到,synchronized可以选择指定使用哪个对象的Monitor锁。具体使用哪个对象的Monitor锁,在字节码中,通过monitorenter前面的几行字节码来指定。
synchronized关键字底层使用的锁叫做Monitor锁。但是,我们无法直接创建和使用Monitor锁。Monitor锁是寄生存在的,每个对象都会拥有一个Monitor锁。如果我们想要使用一个新的Monitor锁,我们只需要使用一个新的对象,并在synchronized关键字后,附带声明要使用哪个对象的Monitor锁即可。
-
当使用sychronized修饰方法的时候,编译器只不过是在函数的flags中添加了ACC_SYNCHRONIZED标记而已,其他部分跟没有添加synchronized的函数的字节码相同。
-
当使用synchronized修饰局部代码块的时候,字节码通过monitorenter和monitorexit来标记synchronized的作用范围。但有两点需要再解释一下
- synchronized关键字底层使用的锁叫做Monitor锁。但是,我们无法直接创建和使用Monitor锁。Monitor锁是寄生存在的,每个对象都会拥有一个Monitor锁,在字节码中,通过monitorenter前面的几行字节码来指定。
- 以下字节码中有两个monitorexit,添加第二个monitorexit的目的是为了在代码抛出异常时仍然能解锁。
monitor锁实现原理
synchronized在底层使用不同的锁来实现,重量级锁,轻量级锁,偏向锁等。
实际上,synchronized使用的重量级锁,就是前面提到的对象上的Monitor锁。JVM有不同的实现版本,因此,Monitor锁也有不同的实现方式。在Hotspot JVM实现中,Monitor锁对应的实现类为ObjectMonitor类。因为Hotspot JVM是用C++实现的,所以,ObjectMonitor也是用C++代码定义的。ObjectMonitor包含的代码很多,我们只罗列一些与其基本实现原理相关的成员变量,如下所示。
class ObjectMonitor {
void* volatile _object; // 该Monitor锁所属的对象
void* volatile _owner; // 获取到该Monitor锁的线程
ObjectWaiter* volatile _EntryList; // 存储等待被唤醒的线程
ObjectWaiter* volatile _cxq ; // 没有获取到锁的线程
ObjectWaiter* volatile _WaitSet; // 存储调用了wait()的线程
}
复制代码
monitor如何与对象关联
_object表示该Monitor锁所属的对象,但是如何通过对象来找到对应的Monitor锁呢?对象的存储结构如下:
其中Mark Word是个可变字段,根据不同的场景记录不同的信息,monitor锁的信息就是记录在此。
monitor如何实现加锁,解锁
ObjectMonitor Enter方法
互斥锁的基本功能:
- 多个线程竞争获取锁;
- 没有获取到锁的线程排队等待获取锁;
- 锁释放之后会通知排队等待锁的线程去竞争锁;
- 没有获取锁的线程会阻塞,并且对应的内核线程不再分配时间片;
- 阻塞线程获取到锁之后取消阻塞,并且对应的内核线程恢复分配时间片。
其中加锁源代码如下:
void ObjectMonitor::EnterI(TRAPS) {
...
// Try the lock - TATAS
if (TryLock (Self) > 0) {
assert(_succ != Self, "invariant");
assert(_owner == Self, "invariant");
assert(_Responsible != Self, "invariant");
return;
}
...
// We try one round of spinning *before* enqueueing Self.
//
// If the _owner is ready but OFFPROC we could use a YieldTo()
// operation to donate the remainder of this thread's quantum
// to the owner. This has subtle but beneficial affinity
// effects.
if (TrySpin (Self) > 0) {
assert(_owner == Self, "invariant");
assert(_succ != Self, "invariant");
assert(_Responsible != Self, "invariant");
return;
}
...
ObjectWaiter node(Self);
// Push "Self" onto the front of the _cxq.
// Once on cxq/EntryList, Self stays on-queue until it acquires the lock.
// Note that spinning tends to reduce the rate at which threads
// enqueue and dequeue on EntryList|cxq.
ObjectWaiter * nxt;
for (;;) {
node._next = nxt = _cxq;
if (Atomic::cmpxchg(&node, &_cxq, nxt) == nxt) break;
// Interference - the CAS failed because _cxq changed. Just retry.
// As an optional optimization we retry the lock.
if (TryLock (Self) > 0) {
assert(_succ != Self, "invariant");
assert(_owner == Self, "invariant");
assert(_Responsible != Self, "invariant");
return;
}
}
...
for (;;) {
if (TryLock(Self) > 0) break;
...
if ((SyncFlags & 2) && _Responsible == NULL) {
Atomic::replace_if_null(Self, &_Responsible);
}
// park self
if (_Responsible == Self || (SyncFlags & 1)) {
TEVENT(Inflated enter - park TIMED);
Self->_ParkEvent->park((jlong) recheckInterval);
// Increase the recheckInterval, but clamp the value.
recheckInterval *= 8;
if (recheckInterval > MAX_RECHECK_INTERVAL) {
recheckInterval = MAX_RECHECK_INTERVAL;
}
} else {
TEVENT(Inflated enter - park UNTIMED);
Self->_ParkEvent->park();
}
if (TryLock(Self) > 0) break;
...
}
...
if (_Responsible == Self) {
_Responsible = NULL;
}
// 善后处理,比如将当前线程从等待队列 CXQ 中移除
...
}
复制代码
多个线程竞争获取锁
多个线程同时请求获取Monitor锁时,JVM会通过CAS操作,先检查_owner
是否是null,如果_owner
是null,再将自己的Thread对象的地址赋值给_owner
,那么谁就获取到了monitor锁。
源代码:
int ObjectMonitor::TryLock (Thread * Self) {
for (;;) {
void * own = _owner ;
if (own != NULL) return 0 ;
if (Atomic::cmpxchg_ptr (Self, &_owner, NULL) == NULL) {
// Either guarantee _recursions == 0 or set _recursions = 0.
assert (_recursions == 0, "invariant") ;
assert (_owner == Self, "invariant") ;
// CONSIDER: set or assert that OwnerIsThread == 1
return 1 ;
}
// The lock had been free momentarily, but we lost the race to the lock.
// Interference -- the CAS failed.
// We can either return -1 or retry.
// Retry doesn't make as much sense because the lock was just acquired.
if (true) return -1 ;
}
}
复制代码
源码中有段需要注意的是:先检查再执行这类复合操作是非线程安全的,那么就会存在多个线程有可能同时检查到_owner
为null的情况,然后都去改变_owner
。为了解决这个问题,JVM采用CPU提供的cmpxchg_ptr
指令,通过给总线加锁的方式,来保证了以上CAS操作的线程安全性。
没有获取到锁的线程排队等待获取锁
多个线程竞争Monitor锁,成功获取到锁的线程就去执行代码,没有获取到锁的线程会放入ObjectMonitor的_cxq单向链表中等待锁
锁释放之后会通知排队等待锁的线程去竞争锁
当持有Monitor锁的线程释放了锁之后,JVM会从_EntryList
中取出一个线程,再取竞争Monitor锁。
如果_EntryList
中没有线程,JVM会先将_CXQ
中所有线程全部搬移到_EntryList
中,然后再从_EntryList
中取线程。
没有获取锁的线程会阻塞,并且对应的内核线程不再分配时间片
一个java线程会对应一个内核线程。应用程序会将java线程要执行的代码,交给其对应的内核线程来执行。内核线程在执行过程中,如果没有竞争到锁,则内核线程会调用park()函数将自己阻塞,这样CPU就不再分配时间片给它。
阻塞线程获取到锁之后取消阻塞,并且对应的内核线程恢复分配时间片
持有锁的线程在释放锁之后,从_EntryList
中取出一个线程时,就会调用unpark()函数,取消对应内核线程的阻塞状态,这样它才能有机会去竞争monitor锁
ObjectMonitor Enter方法总结:
- ObjectMonitor 内部通过一个 CXQ 队列保存所有的等待线程
- 在实际进入队列之前,会反复尝试 lock,在某些系统上会存在 CPU 亲和力的优化
- 入队的时候,通过 ObjectWaiter 对象将当前线程包裹起来,并且入到 CXQ 队列的头部
- 入队成功以后,会根据当前线程是否为第一个等待线程做不同的处理
- 如果是第一个等待线程,会根据一个简单的「退避算法」来有条件的 wait
- 如果不是第一个等待线程,那么会执行无限期等待
- 线程的 park 在 posix 系统上是通过 pthread_cond_wait() 实现的
- 当一个线程获得对象锁成功之后,就可以执行自定义的同步代码块了
ObjectMonitor exit方法
当前线程执行完代码块以后,会进入到ObjectMonitor exit方法,释放当前对象锁,方便下一个线程来获取这个锁,下面我们逐步分析下 exit 的实现过程。
exit函数方法较长,但是整体上的结构比较清晰。
void ObjectMonitor::exit(bool not_suspended, TRAPS) {
for (;;) {
//...
ObjectWaiter * w = NULL;
int QMode = Knob_QMode;
if (QMode == 2 && _cxq != NULL) {
//
}
if (QMode == 3 && _cxq != NULL) {
//
}
if (QMode == 4 && _cxq != NULL) {
//
}
// ...
if (QMode == 1) {
//
} else {
// QMode == 0 or QMode == 2
}
// ...
}
}
复制代码
上面的 exit 函数整体上分为如下几个部分:
- 根据 Knob_QMode 的值和 _cxq 是否为空执行不同策略
- 根据一定策略唤醒等待队列中的下一个线程
其中Knob_QMode这个变量主要用来指定在 exit 的时候 EntryList 和 CXQ 队列之间的唤醒关系,也就是说,当 EntryList 和 CXQ 中都有等待的线程时,因为 exit 之后只能有一个线程得到锁,这个时候选择唤醒哪个队列中的线程是一个值得考虑的事情。JVM默认值为0,我暂时没有找到可以修改Knob_QMode的方法,除了重新编译JVM源代码,所以,这里我们暂时只讨论Knob_QMode=0的情况。代码如下:
void ObjectMonitor::exit(bool not_suspended, TRAPS) {
...
for(;;) {
ObjectWaiter * w = NULL ;
w = _EntryList ;
if (w != NULL) {
assert (w->TState == ObjectWaiter::TS_ENTER, "invariant") ;
ExitEpilog (Self, w) ;
return ;
}
w = _cxq ;
if (w == NULL) continue ;
for (;;) {
assert (w != NULL, "Invariant") ;
ObjectWaiter * u = (ObjectWaiter *) Atomic::cmpxchg_ptr (NULL, &_cxq, w) ;
if (u == w) break ;
w = u ;
}
assert (w != NULL , "invariant") ;
assert (_EntryList == NULL , "invariant") ;
// 抽离出来的QMode == 0 or QMode == 2情况下代码;
_EntryList = w ;
ObjectWaiter * q = NULL ;
ObjectWaiter * p ;
// 将单向链表构造成双向环形链表;
for (p = w ; p != NULL ; p = p->_next) {
guarantee (p->TState == ObjectWaiter::TS_CXQ, "Invariant") ;
p->TState = ObjectWaiter::TS_ENTER ;
p->_prev = q ;
q = p ;
}
// _succ表示已经存在唤醒的线程;
if (_succ != NULL) continue;
w = _EntryList ;
if (w != NULL) {
guarantee (w->TState == ObjectWaiter::TS_ENTER, "invariant") ;
ExitEpilog (Self, w) ;
return ;
}
}
}
复制代码
- 若EntryList队列的头节点_EntryList不为null,那么直接唤醒该头节点封装的线程,然后返回;
- 1的条件不满足,程序继续向下执行,若cxq队列的头节点_cxq为null,则跳过当次循环;
- 若程序继续向下执行说明cxq队列不为空,EntryList队列为空。接下来是一个内嵌的for循环,目的是取出cxq队列中的所有元素,方法是通过一个临时变量指针获得构成队列的整个链表,然后将_cxq指针置为NULL;
- 第二个内嵌for循环是
QMode == 0
策略的内容,目的在于将第三步得到的单向链表倾倒(drain)进EntryList队列,具体方法是将_EntryList指针指向单向链表的头节点,然后通过for循环将单向链表构造成双向环形链表; - 通过ExitEpilog函数释放monitor锁并唤醒EntryList队列的头节点;
锁优化
锁的类型
对于一个synchronized锁,如果它只被一个线程使用,那么,synchronzied锁底层使用偏向锁来实现。如果它被多个线程交叉使用(你用完我再用),不存在竞争使用的情况,那么,synchronized锁底层使用轻量级锁来实现。如果它存在被竞争使用的情况,那么,synchronized锁底层使用重量级锁来实现。
上面再讲到重量级锁需要用到对象头的Mark Word,实际上,偏向锁和轻量级锁也要用到Mark Word。
无锁在Mark Word中的记录有unused(25bits)、hashCode(31bits)、cms_free、GC age、偏向1、锁标志位01
偏向锁
偏向锁在Mark Word中的记录有,threadId(51bits)、epoch(2bits)、cms_free(1bit)、GC age(4bits)、偏向1,锁标志位01
如果我们设置了JVM参数-XX:BiasedLockingStartupDelay=0
,那么,Mark Word会在对象创建之后,直接进入偏向锁状态。
如上图所示:
- 当一个对象被创建出来,还没有持有偏向锁,此时Mark Word字段中的threadID为0,当前线程会使用CPU提供的CAS原子操作来竞争这个偏向锁。
- 当threadID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中threadID设置为当前线程ID
- 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
- 如果CAS已经成功获取到偏向锁,那就开始执行代码,如果执行完代码,线程也不会立刻解锁偏向锁,也就是不会更改threadID为0。这是偏向锁有别于轻量级锁和重量级锁。这样做的目的是提高加锁的效率,当同一个线程再次请求这个偏向锁的时候,线程会查看Mark Word,发现还是处于偏向锁状态,并且threadID就是自己的threadID,线程不再需要做任何加锁操作,就可以直接执行业务代码。
- 偏向锁不会主动解锁,当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁,JVM需要暂停持有偏向锁的线程,然后查看它是否还在使用这个偏向锁,如果线程不再使用这个偏向锁,那么jvm就会将Mark Word设置为无锁状态。如果线程还在使用这个偏向锁,那么虚拟机就将偏向锁升级为轻量级锁。
jvm需要根据持有偏向锁的线程是否正在使用偏向锁,来决定将锁升级为无锁还是偏向锁,这是一个CAS的复合操作,存在线程安全问题,但又无法使用CPU提供的CAS指令来实现,所以解决方案就是jvm会复用垃圾回收器中的STW功能,来停止持有偏向锁的线程。
轻量级锁和自旋锁
当一个线程去竞争锁时,它会先检查Mark Word的的锁标志位,如果锁标志位是01并且相邻偏向位为0(无锁状态)或锁标志位是00(轻量级锁状态),那么,这就说明锁已经升级到了轻量级锁。
如果是无锁状态,jvm会将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方称之为Displaced Mark Word),其目的主要是为了轻量级锁解锁时快速恢复到无锁状态。
拷贝成功后,JVM将使用CAS操作尝试将Mark Word中的Lock Record指针更新为指向自己的Lock Record。
如果更新成功,那么这个线程就拥有了该对象的锁,并将对象的Mark Word的标志位设置为“00”,表示此对象已经是轻量级锁状态。
如果更新失败,按理来说应该要升级成重量级锁,但是JVM对此做了优化,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数(默认是10次,可以使用-XX:PreBlockSpin来更改),或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
这里有个问题,自旋多少次合适?如果自旋次数太少,有可能刚升级为重量级锁,另一个线程就释放了轻量级锁,这样就很可惜。如果自旋次数很多,CPU就会做了很多无用功。针对这个问题,JVM发明了自适应自旋锁。
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁)。如果上次自旋之后成功等到了另一个线程释放轻量级锁,那么下次自旋的次数就增加,如果上次自旋没有等到等到另一个线程释放轻量级锁,那么下次自旋的次数就减少。
如果线程自旋等待轻量级锁失败,只能将轻量级线程升级为重量级线程。跟偏向锁升级不同的是,轻量级锁升级不需要STW,因为所有的CAS操作都是由硬件提供的原子CAS指令来完成的。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。这个就是创建Monitor锁的过程
总结
综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
锁消除
JVM在执行JIT编译的时候,会根据对代码的逃逸分析,去掉某些没有必要的锁。例如:
public String concat(String s1, String s2){
StringBuffer buffer = new StringBuffer();
buffer.append(s1).append(s2);
return buffer.toString();
}
复制代码
StringBuffer中的append函数使用了Sychronized修饰,加了锁,但是,buffer是局部变量,不会被多线程共享,更不会在多线程环境下调用它的append()函数,所以append函数的锁可以被优化消除。
锁粗化
JVM在执行JIT编译时,可能会扩大锁的范围,对多个小范围代码的加锁,合并成一个对大范围代码加锁的操作叫做锁粗化。例如:
private StringBuffer buffer = new StringBuffer();
public void reproduce(String s){
for(int i = 0; i < 10000; i ++){
buffer.append(s);
}
}
复制代码
执行10000次append,会加锁解锁10000次,通过锁粗化,编译器将append函数的锁去掉,移到for循环外面,这样只需要加锁解锁一次就可以了。文章来源:https://www.toymoban.com/news/detail-494795.html
Synchronized的缺点
- 无法判断获取锁的状态。
- 虽然会自动释放锁,但如果如果锁的那个方法执行时间较长就会一直占用着不去释放,不能让使用同一把锁的方法继续执行,影响程序的运行。不能设置超时。
- 当多个线程尝试获取锁时,未获取到锁的线程会不断的尝试获取锁,而不会发生中断,这样会造成性能消耗。
- 不能够实现公平锁
作者:Shawn_Shawn
原文链接:https://juejin.cn/post/7195569817940672572
文章来源地址https://www.toymoban.com/news/detail-494795.html
到了这里,关于java线程-synchronized详解的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!