作为Java程序员可能使用synchronized的频次不是很多大部分时间都是在crud,但是作为一个技术人还是要有点追求的,本篇文章想写了好久在此之前需要了解Java运行时数据区、Java字节码与字节码文件、Java线程模型(挖坑),再此基础上细致的聊一聊Java为了实现线程安全synchronized都做了那些事情。
synchronized是Java的一个关键字,可以把任意一个非null引用对象作为锁对象。在使用上可以放到方法的定义上,也可以锁住某一部分代码块,但是在实际效果上放到静态方法上和锁住该静态方法所在的class是相同的,除此之外放到实例方法上和锁住对象this是效果相同的。
public static synchronized void test1() {
}
该种使用方法实际上是锁了当前类的Class对象。
public synchronized void test3() {
}
该种使用方法实际上是锁了当前实例对象this。
public void test2() {
synchronized (ByteCodeTest.class) {
}
}
public void test4() {
synchronized (this) {
}
}
test2的效果和作用在静态方法上是相同的,当然synchronized里面如果写其他的class就不相同了。test4的效果和作用在实例方法上是相同的,同样的如果synchronized里面不是this就不相同了。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)《深入理解Java虚拟机》。
HotSpot虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据官方称它为“Mark Word”,对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(并不是所有的虚拟机实现都必须在对象数据上保留类型指针)。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0。当然还有其他状态他们的布局也不仅相同。
里面存的是一个地址,占32位或64位,是一个指向当前对象所属于的类的地址,可以通过这个地址获取到它的元数据信息。klass 包含类的元数据信息,像类的方法,常量池等。你可以把它当作 java 里的 java.lang.Class 对象。如果应用的对象过多,使用64位的指针将浪费大量内存。64位的JVM比32位的JVM多耗费50%的内存。 现在使用的64位 JVM会默认使用选项+UseCompressedOops 开启指针压缩,将指针压缩至32位。
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者
2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
我们可以把Monitor理解为一个同步工具,也可以认为是一种同步机制。它通常被描述为一个对象,所有的Java对象都是天生的Monitor,每一个Java对象都有成为Monitor的潜质。因为在Java的设计中 ,每一个Java对象都带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。
偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
前面已经讲过Mark Word的结构了,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。注意这个时候一定是不需要获取Monitor对象的,有很多网上的文章讲到synchronized就会讲进入同步块就获取Monitor对象,好像synchronized和Monitor是强绑定一样,其实不是的如果不是重量级锁是不需要Monitor对象的,这点一定要能分清楚。
偏向锁的出现是有条件的首先是开启了偏向锁(-XX:+UseBiased Locking),其次由于JVM刚刚启动的时候会创建大量的类,而且虚拟机刚启动时用到的这些类大多数是竞争非常多的,那么在这个时候刚启动的时候会默认偏向失效当然这个也是可以由参数控制的-XX:BiasedLockingStartupDelay=0表示从第几ms偏向模式生效。除了虚拟机的参数用户的非锁操作也可以使偏向失效比如计算hashcode值。
偏向锁出现在同步资源只分配给一个线程的情况下,如果同一个同步资源有两个线程都会获取那么这时就会触发锁升级,如果没有竞争则升级成轻量级锁,如果有竞争且严重会从轻量级锁升级成重量级锁。
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。
虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态。
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
需要注意的是轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
public class ByteCodeTest {
public static synchronized void test1() {
}
public void test2() {
synchronized (ByteCodeTest.class) {
}
}
public synchronized void test3() {
}
public void test4() {
synchronized (this) {
}
}
}
public class ByteCodeTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#21 // java/lang/Object."":()V
#2 = Class #22 // ByteCodeTest
#3 = Class #23 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 LByteCodeTest;
#11 = Utf8 test1
#12 = Utf8 test2
#13 = Utf8 StackMapTable
#14 = Class #22 // ByteCodeTest
#15 = Class #23 // java/lang/Object
#16 = Class #24 // java/lang/Throwable
#17 = Utf8 test3
#18 = Utf8 test4
#19 = Utf8 SourceFile
#20 = Utf8 ByteCodeTest.java
#21 = NameAndType #4:#5 // "":()V
#22 = Utf8 ByteCodeTest
#23 = Utf8 java/lang/Object
#24 = Utf8 java/lang/Throwable
{
public ByteCodeTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LByteCodeTest;
public static synchronized void test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 4: 0
public void test2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class ByteCodeTest
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
LineNumberTable:
line 7: 0
line 8: 5
line 9: 15
LocalVariableTable:
Start Length Slot Name Signature
0 16 0 this LByteCodeTest;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 10
locals = [ class ByteCodeTest, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public synchronized void test3();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 12: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this LByteCodeTest;
public void test4();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
LineNumberTable:
line 15: 0
line 16: 4
line 17: 14
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 this LByteCodeTest;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 9
locals = [ class ByteCodeTest, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "ByteCodeTest.java"
方法级的同步是隐式的,可以看到如果是在方法上加了synchronized的那么编译出来的字节码文件里方法的flags上有ACC_SYNCHRONIZED,如果是静态的还会有ACC_STATIC标记,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有锁,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放锁。在方法执行期间,执行线程持有了锁,其他任何线程都无法再获取到同一个锁。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法边界之外时自动释放。
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持,编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须有其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有的异常,它的目的就是用来执行monitorexit指令。
public void test2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 //把常量池的#2入栈 class ByteCodeTest
2: dup //栈顶元素复制
3: astore_1 //将栈顶引用类型值保存到局部变量1中
4: monitorenter //栈定元素作为锁
5: aload_1 //从局部变量1值入栈
6: monitorexit //退出同步
7: goto 15 //正常退出跳转到15
10: astore_2 //将栈顶引用类型值保存到局部变量2中
11: aload_1 //从局部变量1值入栈
12: monitorexit //退出同步
13: aload_2 //从局部变量2值入栈
14: athrow //把异常对象重新抛出给test2()方法的调用者
15: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
LineNumberTable:
line 7: 0
line 8: 5
line 9: 15
LocalVariableTable:
Start Length Slot Name Signature
0 16 0 this LByteCodeTest;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 10
locals = [ class ByteCodeTest, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public void test4();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0 //把局部变量表槽位0入栈
1: dup //栈顶元素复制
2: astore_1 //栈顶元素保存到局部变量表槽位1
3: monitorenter //栈定元素作为锁
4: aload_1 //把局部变量表槽位1入栈
5: monitorexit //退出同步
6: goto 14 //正常退出跳转到14
9: astore_2 //将栈顶引用类型值保存到局部变量2中
10: aload_1 //从局部变量1值入栈
11: monitorexit //退出同步
12: aload_2 //从局部变量2值入栈
13: athrow //把异常对象重新抛出给test4()方法的调用者
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
LineNumberTable:
line 15: 0
line 16: 4
line 17: 14
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 this LByteCodeTest;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 9
locals = [ class ByteCodeTest, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
从上面两段同步快逻辑其实可以看出来synchronized不管锁住的是个类对象还是个实例对象其实加锁的过程和退出通过的过程都一样,区别只在于用作锁的的对象是来自于常量池还是局部变量表,同样的synchronized修饰方法也是一样的,所以在回答关于synchronized添加到不同位置两个线程能不能异步执行的面试题的时候心里也就有底气了吧,只要被用作锁的那个对象是同一个就只能同步执行否则可以异步执行。针对于示例方法test1和test2同步执行,test3和test4同步执行。
一般情况下一个资源如果只有一个线程获取过那么有可能是偏向锁(这种情况下导致无法偏向的原因可能是计算过hashcode、JVM刚启动时未开启偏向锁、JVM配置了禁用偏向锁等),如果一个资源有两个及以上线程在使用并且是交替执行或者竞争非常小(锁的自旋等待)这个时候回是轻量级锁,如果一个资源有两个及以上线程使用并且竞争比较激烈那么这个时候会是重量级锁。
<dependency>
<groupId>org.openjdk.jolgroupId>
<artifactId>jol-coreartifactId>
<version>0.16version>
dependency>
public class MarkWordExp {
public static void main(String[] args) {
Object monitor = new Object();
new Thread(new Test(monitor)).start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new Test(monitor)).start();
}
static class Test implements Runnable {
private Object o;
public Test(Object o) {
this.o = o;
}
@Override
public void run() {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
}
/**
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007f792909a005 (biased: 0x0000001fde4a4268; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000003065eea38 (thin lock: 0x00000003065eea38)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
*/
可以看到第一个线程和第二个线程其实没有竞争这个锁但是还是从偏向锁升级成了轻量级锁,原因就是因为偏向锁加锁需要对对象的MarkWork操作但是偏向锁就像是渣男,拿到了这个锁之后就去运行退出同步代码块的时候也不做任何操作不管以后用不用的到这个锁都不去释放,导致其他线程来了之后发现这个锁已经偏向别人了,只能抢占这把锁如果抢占成功就是轻量级锁,如果抢占失败就是重量级锁。
public class MarkWordExp {
public static void main(String[] args) {
Object monitor = new Object();
new Thread(new Test(monitor, 10)).start();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new Test(monitor, 0)).start();
}
static class Test implements Runnable {
private Object o;
private int sleepTime;
public Test(Object o, int sleepTime) {
this.o = o;
this.sleepTime = sleepTime;
}
@Override
public void run() {
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
try {
TimeUnit.SECONDS.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
/**
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007fc524828805 (biased: 0x0000001ff14920a2; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007fc524011b5a (fat lock: 0x00007fc524011b5a)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
*/
可以看到第二个线程获取锁的时候第一个线程还没释放,升级成重量级锁了。