温故知新-多线程-深入刨析synchronized
- Posted by 微博@Yangsc_o
- 原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0
文章目录
摘要
本文从synchronized的基本用法,结合对象的内存布局,深入探究synchronized的锁升级的过程,探究其实现原理。准备开始~
synchronized的基础用法
-
修饰普通同步⽅方法
对于非static的情况,synchronized是对象级别的,其实质是将synchronized作用于对象引用(object reference)上,即拿到p1对象锁的线程,对的fun()方法有同步互斥作用,不同的对象之间坚持“和平共处”。
// 锁的对象是方法的调用者 public synchronized void method(){ }
上边的示例代码等同于如下代码:
public void method() { synchronized (this) { } }
-
修饰静态同步方法
如果方法用static修饰,synchronized的作用范围就是class一级的,它对类的所有对象起作用。
Class Foo{ public static synchronized void method1() { } }
上边的示例代码等同于如下代码:
public void method2() { synchronized(Foo.class) { // class literal(类名称字面常量) //请注意,Foo.class也是一个对象,类型是Class,在一个ClassLoader里,它是唯一的。 } }
-
修饰同步代码块
锁就是so这个对象,谁拿到这个锁谁就能够运行他所控制的那段代码。
同步块,示例代码如下:
public void method(SomeObject so) { synchronized(so) { //….. } }
当有一个明确的对象作为锁时,就能够这样写代码,但当没有明确的对象作为锁,只是想让一段代码同步时,能够创建一个特别的instance变量(它得是个对象)来充当锁
class Foo implements Runnable{ private byte[] lock = new byte[0]; // 特别的instance变量 Public void method(){ synchronized(lock) { } }
- 注:零长度的byte数组对象创建起来将比任何对象都经济。查看编译后的字节码:生成零长度的byte[]对象只需3条操作码,而Object lock = new Object()则需要7行操作码。
特殊说明
- 使⽤用synchronized修饰类和对象时,由于类对象和实例例对象分别拥有⾃自⼰己的监视器器锁,因此不不会
相互阻塞 - 使⽤synchronized修饰实例例对象时,如果一个线程正在访问实例例对象的一个synchronized方法时,其它线程不仅不能访问该synchronized⽅法,该对象的其它synchronized方法也不不能访问,因为一个对象只有一个监视器器锁对象,但是其它线程可以访问该对象的非synchronized⽅法。
synchronized原理
字节码理解
在说synchronized原理时,就不得不先了解一下Monitor
认识 Java Monitor Object
Java Monitor 从两个方面来支持线程之间的同步,即:互斥执行(对象内的所有方法都互斥的执行。好比一个 Monitor 只有一个运行许可,任一个线程进入任何一个方法都需要获得这个许可,离开时把许可归还)与协作(通常提供signal机制:允许正持有许可的线程暂时放弃许可,等待某个监视条件成真,条件成立后,当前线程可以通知正在等待这个条件的线程,让它可以重新获得运行许可)。 Java 使用对象锁 ( 使用 synchronized 获得对象锁 ) 保证工作在共享的数据集上的线程互斥执行 , 使用 notify/notifyAll/wait 方法来协同不同线程之间的工作。这些方法在 Object 类上被定义,会被所有的 Java 对象自动继承。
Java 语言对于这样一个典型并发设计模式做了内建的支持,线程如果获得监视锁成功,将成为该监视者对象的拥有者。在任一时刻内,监视者对象只属于一个活动线程 (Owner) 。拥有者线程可以调用 wait 方法自动释放监视锁,进入等待状态。下图很好地描述了 Java Monitor 的工作机理。
在Java虚拟机(HotSpot)中, monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp⽂文件,C++实现的),位于/openjdk-8u60/hotspot/src/share/vm/runtime/objectMonitor.hpp
ObjectMonitor() {
_header = NULL; //markOop对象头
_count = 0; // 可以⼤大于1,可重⼊入
_waiters = 0, //等待线程数
_recursions = 0; //重⼊入次数
_object = NULL; //监视器器锁寄⽣生的对象。锁不不是平⽩白出现的,⽽而是寄托存储于对象中。
_owner = NULL; //初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯⼀一标识,当锁被释放时⼜又设置为NULL
_WaitSet = NULL; //处于wait状态的线程,会被加⼊入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加⼊入到该列列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
每个线程都有两个ObjectMonitor对象列列表,分别为free和used列列表,如果当前free列列表为空,线程将
向全局global list请求分配ObjectMonitor。thread结构/openjdk-8u60/hotspot/src/share/vm/runtime/thread.hpp
// Private thread-local objectmonitor list - a simple cache organized as a SLL.
public:
ObjectMonitor* omFreeList;
int omFreeCount; // length of omFreeList
int omFreeProvision; // reload chunk size
ObjectMonitor* omInUseList; // SLL to track monitors in circulation
int omInUseCount; // length of omInUseList
ObjectMonitor中有两个队列列, _WaitSet 和 _EntryList,用来保存ObjectWaiter对象列列表( 每个等待锁的线程都会被封装成ObjectWaiter对象), owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进⼊入 EntryList 集合,当线程获取到对象的monitor 后进⼊ _Owner 区域并把monitor中的owner变量量设置为当前线程同时monitor中的计数器器count加1,若线程调⽤用 wait() ⽅法,将释放当前持有的monitor, owner变量量恢复为null, count⾃自减1,同时该线程进⼊入 WaitSet集合中等待被唤醒。若当前线程执⾏行行完毕也将释放monitor(锁)并复位变量量的值,以便便其他线程进⼊入获取monitor(锁)。
由此看来, monitor对象存在于每个Java对象的对象头中(存储的指针的指向), synchronized锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因。
代码验证
是不是一脸崩?不知道在说什么🤷♂️?
-
先看一张图
概括一下就是synchronized后,在程序执行的过程中会有被监听的过程,过程可以概括为:获取监听器、dosomething、释放监听器
- 测试验证代码
public class ObjectLayout {
public static synchronized void test(){
}
public synchronized void test1(){
}
public static void main(String[] args) {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj){
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
ObjectLayout.test();
ObjectLayout objectLayout = new ObjectLayout();
objectLayout.test1();
}
}
- 编译为字节码
0 new #2 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init>>
7 astore_1
8 getstatic #3 <java/lang/System.out>
11 aload_1
12 invokestatic #4 <org/openjdk/jol/info/ClassLayout.parseInstance>
15 invokevirtual #5 <org/openjdk/jol/info/ClassLayout.toPrintable>
18 invokevirtual #6 <java/io/PrintStream.println>
21 aload_1
22 dup
23 astore_2
24 monitorenter
25 getstatic #3 <java/lang/System.out>
28 aload_1
29 invokestatic #4 <org/openjdk/jol/info/ClassLayout.parseInstance>
32 invokevirtual #5 <org/openjdk/jol/info/ClassLayout.toPrintable>
35 invokevirtual #6 <java/io/PrintStream.println>
38 aload_2
39 monitorexit
40 goto 48 (+8)
43 astore_3
44 aload_2
45 monitorexit
46 aload_3
47 athrow
48 invokestatic #7 <com/yangsc/juc/ObjectLayout.test>
51 new #8 <com/yangsc/juc/ObjectLayout>
54 dup
55 invokespecial #9 <com/yangsc/juc/ObjectLayout.<init>>
58 astore_2
59 aload_2
60 invokevirtual #10 <com/yangsc/juc/ObjectLayout.test1>
63 return
可以看到,在synchronized (obj){}被monitorenter和moniterexit包裹了!
-
静态同步⽅法&同步⽅法
字节码中会标记为volatile,后续交由虚拟机处理;
虚拟机实现原理
一个对象的内存布局
在更进一步的深入之前,我们需要了解一下Java对象在内存的布局,一个Java对象包括对象头、实例数据和补齐填充3个部分:
对象头
- Mark Word:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在32位系统占4字节,在64位系统中占8字节;
- Class Pointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
- Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;
对象实际数据
对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节(64位系统中是8个字节)。
对于reference类型来说,在32位系统上占用4bytes, 在64位系统上占用8bytes。
对齐填充
Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。
注意:以上对64位操作系统的描述是未开启指针压缩的情况,关于指针压缩会在下文中介绍。
对象头占用空间大小
这里说明一下32位系统和64位系统中对象所占用内存空间的大小:
- 在32位系统下,存放Class Pointer的空间大小是4字节,MarkWord是4字节,对象头为8字节;
- 在64位系统下,存放Class Pointer的空间大小是8字节,MarkWord是8字节,对象头为16字节;
- 64位开启指针压缩的情况下,存放Class Pointer的空间大小是4字节,
MarkWord
是8字节,对象头为12字节; - 如果是数组对象,对象头的大小为:数组对象头8字节+数组长度4字节+对齐4字节=16字节。其中对象引用占4字节(未开启指针压缩的64位为8字节),数组
MarkWord
为4字节(64位未开启指针压缩的为8字节); - 静态属性不算在对象大小内。
指针压缩
从上文的分析中可以看到,64位JVM消耗的内存会比32位的要多大约1.5倍,这是因为对象指针在64位JVM下有更宽的寻址。对于那些将要从32位平台移植到64位的应用来说,平白无辜多了1/2的内存占用,这是开发者不愿意看到的。
从JDK 1.6 update14开始,64位的JVM正式支持了 -XX:+UseCompressedOops 这个可以压缩指针,起到节约内存占用的新参数。
什么是OOP?
OOP的全称为:Ordinary Object Pointer,就是普通对象指针。启用CompressOops后,会压缩的对象:
- 每个Class的属性指针(静态成员变量);
- 每个对象的属性指针;
- 普通对象数组的每个元素指针。
当然,压缩也不是所有的指针都会压缩,对一些特殊类型的指针,JVM是不会优化的,例如指向PermGen的Class对象指针、本地变量、堆栈元素、入参、返回值和NULL指针不会被压缩。
启用指针压缩
在Java程序启动时增加JVM参数:-XX:+UseCompressedOops
来启用。
注意:32位HotSpot VM是不支持UseCompressedOops参数的,只有64位HotSpot VM才支持。
本文中使用的是JDK 1.8,默认该参数就是开启的。
扯了半天一个java对象在内存的布局,其实针对此文,重点需要关注markword
我们也可以在jdk的’/openjdk-8u60/hotspot/src/share/vm/memory/universe.hpp’中看到相关描述
// Bit-format of an object header (most significant first, big endian layout below):
//
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
//
------------------省略部分描述------------------
// [JavaThread* | epoch | age | 1 | 01] lock is biased toward given thread
// [0 | epoch | age | 1 | 01] lock is anonymously biased
//
// - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
// [ptr | 00] locked ptr points to real header on stack
// [header | 0 | 01] unlocked regular object header
// [ptr | 10] monitor inflated lock (header is wapped out)
// [ptr | 11] marked used by markSweep to mark an object
// not valid at any other time
//
// We assume that stack/thread pointers have the lowest two bits cleared.
锁升级的过程:无锁 - 偏向锁 -轻量级锁(自旋锁)-重量级锁
-
偏向锁: markword 上记录当前线程指针,下次同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,所以,偏向锁,偏向加锁的第一个线程 。hashCode备份在线程栈上 线程销毁,锁降级为无锁
-
轻量级锁:有争用 - 锁升级为轻量级锁 - 每个线程有自己的LockRecord在自己的线程栈上,用CAS去争用markword的LR的指针,指针指向哪个线程的LR,哪个线程就拥有锁
-
重量级锁:自旋超过10次(旧版本),升级为重量级锁 - 如果太多线程自旋 CPU消耗过大,不如升级为重量级锁,进入等待队列(不消耗CPU)-XX:PreBlockSpin
自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
偏向锁由于有锁撤销的过程revoke,会消耗系统资源,所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。
测试验证
public class ObjectLayout1 {
public static void main(String[] args) {
MyObj obj = new MyObj();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
for (int i = 0; i < 100; i++) {
new Thread(()->{
obj.add();
}).start();
}
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
class MyObj {
private Integer count = 0;
public synchronized void add() {
count++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
-
不加锁
00000001 对应无锁状态
com.yangsc.juc.MyObj object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 6e 37 02 f8 (01101110 00110111 00000010 11111000) (-134072466) 12 4 java.lang.Integer MyObj.count 0 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
-
偏向锁
10010000 对应轻量级标志位
com.yangsc.juc.MyObj object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 90 69 30 0c (10010000 01101001 00110000 00001100) (204499344) 4 4 (object header) 00 70 00 00 (00000000 01110000 00000000 00000000) (28672) 8 4 (object header) 6e 37 02 f8 (01101110 00110111 00000010 11111000) (-134072466) 12 4 java.lang.Integer MyObj.count 0 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
-
重量级锁
01111010 对应重量级锁标志位
com.yangsc.juc.MyObj object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 7a a8 80 4e (01111010 10101000 10000000 01001110) (1317054586) 4 4 (object header) ba 7f 00 00 (10111010 01111111 00000000 00000000) (32698) 8 4 (object header) 6e 37 02 f8 (01101110 00110111 00000010 11111000) (-134072466) 12 4 java.lang.Integer MyObj.count 1 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
上面的markword验证锁升级的过程,但是Hotspot是如何实现呢?
翻开虚拟机源码位置:openjdk-8u60/hotspot/src/share/vm/interpreter/interpreterRuntime.cpp
函数:InterpreterRuntime:: monitorenter
注:JDK 1.6中默认开启偏向锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
#ifdef ASSERT
thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
if (PrintBiasedLockingStatistics) {
Atomic::inc(BiasedLocking::slow_path_entry_count_addr());
}
Handle h_obj(thread, elem->obj());
assert(Universe::heap()->is_in_reserved_or_null(h_obj()),
"must be NULL or an object");
if (UseBiasedLocking) {
// 如果 UseBiasedLocking 开启了了偏向锁,优先获得的是偏向锁,否则是轻量量级锁。
// Retry fast entry if bias is revoked to avoid unnecessary inflation
ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
} else {
ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
}
assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),
"must be NULL or an object");
#ifdef ASSERT
thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
IRT_END
- 获取偏向锁入口fast_enter
// -----------------------------------------------------------------------------
// Fast Monitor Enter/Exit
// This the fast monitor enter. The interpreter and compiler use
// some assembly copies of this code. Make sure update those code
// if the following function is changed. The implementation is
// extremely sensitive to race condition. Be careful.
void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock, bool attempt_rebias, TRAPS) {
// 开启了了偏向锁
if (UseBiasedLocking) {
// 没有到达安全点
if (!SafepointSynchronize::is_at_safepoint()) {
// 获取偏向锁
BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
return;
}
} else {
// 释放偏向锁
assert(!attempt_rebias, "can not rebias toward VM thread");
BiasedLocking::revoke_at_safepoint(obj);
}
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
}
// 获取轻量量级锁
slow_enter (obj, lock, THREAD) ;
}
偏向锁的撤销
只有当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,偏向锁的撤销由BiasedLocking::revoke_at_safepoint
方法实现:
-
revoke_and_rebias
偏向锁获取的具体逻辑 /hotspot/src/share/vm/runtime/synchronizer.cpp, 偏向锁的获取由 BiasedLocking::revoke_and_rebias ⽅法实现,这个方法太长了,有兴趣的看一下吧;
-
轻量级锁
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) { markOop mark = obj->mark(); assert(!mark->has_bias_pattern(), "should not see bias pattern here"); if (mark->is_neutral()) { lock->set_displaced_header(mark); 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())) { assert(lock != mark->locker(), "must not re-lock the same lock"); assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock"); lock->set_displaced_header(NULL); return; } #if 0 if (mark->has_monitor() && mark->monitor()->is_entered(THREAD)) { lock->set_displaced_header (NULL) ; return ; } #endif lock->set_displaced_header(markOopDesc::unused_mark()); ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD); }
轻量级锁的释放通过
ObjectSynchronizer::fast_exit
完成。
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
锁膨胀过程,锁的膨胀过程通过ObjectSynchronizer::inflate
函数实现,代码也非常的长,此处省略,有兴趣的可以读一下。
锁消除 lock eliminate
public void add(String str1,String str2){
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。
锁粗化 lock coarsening
public String test(String str){
int i = 0;
StringBuffer sb = new StringBuffer():
while(i < 100){
sb.append(str);
i++;
}
return sb.toString():
}
JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。
总而言之,synchronized为了进⼀一步提升synchronized的性能,提⾼高多线程环境下的并发效率,做了很多努力!
自此,synchronized回顾完毕。