0%

synchronized及其优化

参考博文:
http://www.cnblogs.com/wade-luffy/p/5969418.html
http://www.cnblogs.com/kniught-ice/p/5189997.html
https://www.zhihu.com/question/270564693



一、锁是什么?

在java中对象都可以作为锁。
普通同步方法:锁是当前实例对象。
静态同步方法:锁是当前的class对象
同步方法块:锁是sychonized括号中的对象。


1、根据获取的锁的分类:获取对象锁和获取类锁

获取对象锁的两种用法
  1. 同步代码块synchronized (this) , synchronized (类实例对象),锁是小括号()中的实例对象。
  2. 同步非静态方法 synchronized method , 锁是当前对象的实例对象。
获取类锁的两种用法
  1. 同步代码块synchronized (类.class), 锁是小括号()中的类对象(Class对象)。
  2. 同步静态方法 synchronized static method, 锁是当前对象的类对象(Class对象)。

2、对象锁和类锁的总结

  • 有线程访问对象的同步代码块时 ,另外的线程可以访问该对象的非同步代码块;
  • 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞;
  • 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞;
  • 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然;
  • 同一个类的不同对象的对象锁互不干扰;
  • 类锁由于也是一种特殊的对象锁,因此表现和上述1,2,3,4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的;
  • 类锁和对象锁互不干扰。

3、其他锁相关知识点:

  • jvm是基于进入和退出monitor对象来实现方法的同步和代码块的同步。
  • synchronized锁的不是代码,锁的是对象。
  • Java提供了synchronized关键字来支持内在锁。Synchronized关键字可以放在方法的前面、对象的前面、类的前面。
  • Java虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现,无论是显式同步(有明确的monitorentermonitorexit指令,即同步代码块)还是隐式同步都是如此。


二、synchronized底层实现


1、synchronized底层语义原理

在JVM的规范中,有这么一些话:“在JVM中,每个对象和类在逻辑上都是和一个监视器相关联的,为了实现监视器的排他性监视能力,JVM为每一个对象和类都关联一个锁,锁住了一个对象,就是获得对象相关联的监视器”

在Java中,每个对象都会有一个monitor对象监视器。

  • Java虚拟机中的一个线程在它到达监视区域开始处的时候请求一个锁。JAVA程序中每一个监视区域都和一个对象引用相关联。某一线程占有这个对象的时候,先monitor的计数器是不是0,如果是0还没有线程占有,这个时候线程占有这个对象,并且对这个对象的monitor+1;如果不为0,表示这个线程已经被其他线程占有,这个线程等待。当线程释放占有权的时候,monitor-1;
  • 同一线程可以对同一对象进行多次加锁,+1, +1,重入性,而一个锁就像一种任何时候只允许一个线程拥有的特权.一个线程可以允许多次对同一对象上锁.对于每一个对象来说,java虚拟机维护一个计数器,记录对象被加了多少次锁,没被锁的对象的计数器是0,线程每加锁一次,计数器就加1,每释放一次,计数器就减1.当计数器跳到0的时候,锁就被完全释放了.
  • 在Java语言中,同步用的最多的地方可能是被synchronized修饰的同步方法。同步方法并不是由monitorentermonitorexit指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的ACC_ SYNCHRONIZED标志来隐式实现的。下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。

2、下面我们通过代码进行理解

当使用synchronized关键字对方法加上同步锁
1
2
3
4
5
6
7
8
9
10
11
public class SynchronizedDemo{
private static int m = 0;
public synchronized static void synchronizedFun(){
try {
Thread.sleep(2);
m++;
} catch (InterruptedException e){
e.printStackTrace();
}
}
}

我们对字节码文件使用javap -v时,我们会发现反编译的文件中对方法添加synchronized关键字,会在该方法头的flags中存在一个ACC_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
public static synchronized void synchronizedFun();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=0
0: ldc2_w #2 // long 2l
3: invokestatic #4 // Method java/lang/Thread.sleep:(J)V
6: getstatic #5 // Field m:I
9: iconst_1
10: iadd
11: putstatic #5 // Field m:I
14: goto 22
17: astore_0
18: aload_0
19: invokevirtual #7 // Method java/lang/InterruptedException.printStackTrace:()V
22: return
Exception table:
from to target type
0 14 17 Class java/lang/InterruptedException
LineNumberTable:
line 5: 0
line 6: 6
line 9: 14
line 7: 17
line 8: 18
line 10: 22
StackMapTable: number_of_entries = 2
frame_type = 81 /* same_locals_1_stack_item */
stack = [ class java/lang/InterruptedException ]
frame_type = 4 /* same */
当使用synchronized关键字对代码块加上同步锁
1
2
3
4
5
6
7
8
9
10
11
12
public class SynchronizedDemo{
public static void synchronizedFun(){
try {
synchronized(this){
TimeUnit.MINUTES.sleep(2);
m++;
}
} catch (InterruptedException e){
e.printStackTrace();
}
}
}

我们对字节码文件使用javap -v时,我们会发现反编译的文件中发现第3行出现了monitorexit,第19行和第25行出现了monitorexit,对于为什么只有一个enter却有两个exit是因为,同步锁是原子性的,所以有一个为当失败时进行回滚的exit

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
public void synchronizedFun();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: ldc2_w #2 // long 2l
7: invokestatic #4 // Method java/lang/Thread.sleep:(J)V
10: getstatic #5 // Field m:I
13: iconst_1
14: iadd
15: putstatic #5 // Field m:I
18: aload_1
19: monitorexit
20: goto 28
23: astore_2
24: aload_1
25: monitorexit
26: aload_2
27: athrow
28: goto 36
31: astore_1
32: aload_1
33: invokevirtual #7 // Method java/lang/InterruptedException.printStackTrace:()V
36: return
Exception table:
from to target type
4 20 23 any
23 26 23 any
0 28 31 Class java/lang/InterruptedException
LineNumberTable:
line 5: 0
line 6: 4
line 7: 10
line 8: 18
line 11: 28
line 9: 31
line 10: 32
line 12: 36
StackMapTable: number_of_entries = 4
frame_type = 255 /* full_frame */
offset_delta = 23
locals = [ class SynchronizedDemo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
frame_type = 66 /* same_locals_1_stack_item */
stack = [ class java/lang/InterruptedException ]
frame_type = 4 /* same */


三、理解Java对象头与Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
  • Java头对象,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark WordClass Metadata Address组成,其结构说明如下表:
长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果当前对象是数组)

1、对象头的Mark Word默认存储的对象的hashCode,分代年龄和锁标志位

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashCode 对象分代年龄 0 01

2、 Mark Word的状态变化

在这里插入图片描述



四、synchronized的优化-偏向锁、轻量级锁、重量级锁

  • synchronized是java多线程编程的元老级人物,也被称为重量级锁
  • 偏向锁和轻量级锁之所以会在性能上比重量级锁好是因为本质上偏向锁和轻量级锁仅仅使用了CAS

1、偏向锁:仅适用于锁没有竞争的情况,假设共享变量只有一个线程访问。如果有其他线程竞争锁,锁则会膨胀为轻量级锁。

加锁方式:
  1. 初始时对象处于biasable状态,并且ThreadID为0即biasable&unbiased状态。
  2. 当一个线程视图锁住处于biasable&unbiased状态的对象时,通过一个CAS锁将自己的ThreadID放置到Mark Word中的相应位置,如果CAS操作成功则进入第三步,否则进入第四步。
  3. 当进入此步则表示所没有竞争,Object继续保持biasable状态,但是这是的ThreadID字段设置成了偏向锁所有者的ID,然后执行同步代码块。
  4. 当线程执行CAS获取偏向锁失败,表示在该锁对象上存在竞争并且这个时候另一个线程获得偏向锁的所有权。当到达全局安全点是获取偏向锁的线程被挂起,并将Object设置为LightWeight Lock(轻量级锁)状态并且Mark Word中的Lock Record指向刚才持有偏向锁线程的Monitor record(监视器记录),最后被阻塞在安全点的线程被释放,进入到轻量级锁的执行路径中,同时被撤销偏向锁的线程继续往下执行同步代码。
解锁过程:
  • 偏向锁解锁过程很简单,只需要测试下是否Object上的偏向锁模式是否还存在,如果存在则解锁成功不需要任何其他额外的操作。

2、轻量级锁:适用于锁有多个竞争,但是在一个同步方法块周期中锁不存在竞争,如果在同步周期内有其他线程竞争锁,锁会膨胀为重量级锁。

加锁过程:
  • 线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获取锁,如果失败,则进行自旋获取锁,当自选获取锁仍然失败是,表示当前线程存在两条或两条以上线程竞争同一个锁,则轻量级锁膨胀成重量级锁。
解锁过程:
  • 轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示同步过程已完成。如果失败则表示有其他线程尝试过获取锁,则要将释放锁的同时唤醒被挂起的线程。

3.重量级锁:竞争激烈的情况下使用重量级锁。

  • 重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

4、通过JVM参数来修改锁状态

  • 偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

-

5、自旋锁

  • 线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了自旋锁。

  • 所谓“自旋”,就是让线程去执行一个无意义的循环,循环结束后再去重新竞争锁,如果竞争不到继续循环,循环过程中线程会一直处于running状态,但是基于JVM的线程调度,会出让时间片,所以其他线程依旧有申请锁和释放锁的机会。

  • 自旋锁省去了阻塞锁的时间空间(队列的维护等)开销,但是长时间自旋就变成了“忙式等待”,忙式等待显然还不如阻塞锁。所以自旋的次数一般控制在一个范围内,例如10,100等,在超出这个范围后,自旋锁会升级为阻塞锁。


6、自旋锁和轻量级锁的关系

  • 轻量级锁是一种状态,而自旋锁是一种获取锁的方式。线程首先会通过CAS获取锁,失败后通过自旋锁来尝试获取锁,再失败锁就膨胀为重量级锁。所以轻量级锁状态下可能会有自旋锁的参与(cas将对象头的标记指向锁记录指针失败的时候)


五、偏向锁,轻量级锁,重量级锁对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程使用自旋会消耗CPU 追求响应时间,锁占用时间很短
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,锁占用时间较长