文章目录
- 1. 临界区
- 2. synchronized使用
- 2.1 不加锁实现
- 2.2 synchronized加锁
- 2.3 面向对象的改进
- 2.4 方法上加synchronized
- 2.5 线程安全
- 3. Monitor
- 3.1 Java对象头
- 3.2 Monitor工作流程
- 3.3 字节码角度
- 4. synchronized原理
- 4.1 轻量级锁
- 4.2 锁膨胀
- 4.3 偏向锁
- 4.3.1 偏向锁过程
- 4.3.2 偏向状态
- 4.3.3 撤销偏向
- 4.3.4 批量重偏向
- 4.3.5 批量撤销
- 5. synchronized优化
- 5.1 自旋锁
- 5.2 锁消除
- 5.3 锁粗化
- 5.4 多把锁
1. 临界区
在介绍synchronized锁之前,我们先来了解一下什么是临界区以及临界资源。
临界资源:一次仅允许一个进程使用的资源成为临界资源
临界区:访问临界资源的代码块
竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
一个程序运行多个线程是没有问题,多个线程读共享资源也没有问题,在多个线程对共享资源读写操作时发生指令交错,就会出现问题,而为了让多线程模式下能够有序的使用临界资源,就引入了锁的概念。
为了避免临界区的竞态条件发生(解决线程安全问题):
- 阻塞式的解决方案:synchronized,lock
- 非阻塞式的解决方案:原子变量
管程(Monitor):由局部于自己的若干公共变量和所有访问这些公共变量的过程所组成的软件模块,保证同一时刻只有一个进程在管程内活动,即管程内定义的操作在同一时刻只被一个进程调用(由编译器实现)
synchronized:对象锁,保证了临界区内代码的原子性,采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程获取这个对象锁时会阻塞,保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换,其实现依赖于Monitor
互斥和同步都可以采用 synchronized 关键字来完成,区别:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
性能:
- 线程安全,性能差
- 线程不安全性能好,假如开发中不会存在多线程安全问题,建议使用线程不安全的设计类
2. synchronized使用
synchronized锁使用的语法如下:
synchronized(锁对象){// 访问共享资源的核心代码
}
锁对象:理论上可以是任意的唯一对象
synchronized 是可重入、不公平的重量级锁
原则上:
- 锁对象建议使用共享资源
- 在实例方法中使用 this 作为锁对象,锁住的 this 正好是共享资源
- 在静态方法中使用类名 .class 字节码作为锁对象,因为静态成员属于类,被所有实例对象共享,所以需要锁住类
示例:
如果我们开两个线程对一个变量分别进行加5000次和减5000次,那么结果会是什么呢?
2.1 不加锁实现
不加锁实现的即开一个线程加5000次,开另一个线程减5000次,然后等待线程执行完,查看最后的结果:
@Slf4j(topic = "c.TwoPhaseTermination")
public class Test1 {static int counter = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for(int i = 0;i < 5000;i++){counter++;}}, "t1");Thread t2 = new Thread(()->{for(int i = 0;i < 5000;i++){counter--;}}, "t1");t1.start();t2.start();t1.join();t2.join();log.debug("counter: {}", counter);}
}
这样执行后发现,执行结果并不总是0,这是由于没有同步所造成的。
2.2 synchronized加锁
我们可以使用synchronized关键字对对象进行加锁,达到同步的目的,代码如下:
@Slf4j(topic = "c.TwoPhaseTermination")
public class Test1 {static int counter = 0;static final Object room = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for(int i = 0;i < 5000;i++){synchronized (room){counter++;}}}, "t1");Thread t2 = new Thread(()->{for(int i = 0;i < 5000;i++){synchronized (room){counter--;}}}, "t1");t1.start();t2.start();t1.join();t2.join();log.debug("counter: {}", counter);}
}
2.3 面向对象的改进
可以将上面的锁写为一个对象,然后调用对象中的方法来进行线程同步的加减法,修改如下:
@Slf4j(topic = "c.TwoPhaseTermination")
public class Test1 {public static void main(String[] args) throws InterruptedException {Room room = new Room();Thread t1 = new Thread(()->{for(int i = 0;i < 5000;i++){room.increment();}}, "t1");Thread t2 = new Thread(()->{for(int i = 0;i < 5000;i++){room.decrement();}}, "t1");t1.start();t2.start();t1.join();t2.join();log.debug("counter: {}", room.getCounter());}
}class Room{private int counter = 0;public void increment(){synchronized (this){counter++;}}public void decrement(){synchronized (this){counter--;}}public int getCounter(){return counter;}
}
这样将需要加的锁都加到了另一个对象room中,我们只需要调用room的加减方法即可。
2.4 方法上加synchronized
synchronized的另一种用法是加在方法上,其使用的形式如下:
/** 非静态方法:普通方法锁住的是对象的实例 **/
class Test{public synchronized void test(){}
}// 上面的写法等价于class Test{public void test(){synchronized(this){}}
}/** 静态方法:静态方法的synchronized锁住的是类的Class对象 **/
class Test{public synchronized static void test(){}
}// 上面的写法等价于class Test{public static void test(){synchronized(Test.class){}}
}
2.5 线程安全
线程安全的是指,多个线程调用它们同一个实例的某个方法时,能够达到预期的效果。
成员变量和静态变量:
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,分两种情况:
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全问题
局部变量:
- 局部变量是线程安全的
- 局部变量引用的对象不一定线程安全(逃逸分析):
- 如果该对象没有逃离方法的作用访问,它是线程安全的(每一个方法有一个栈帧)
- 如果该对象逃离方法的作用范围,需要考虑线程安全问题(暴露引用)
常见线程安全类:String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包,他们的每个方法都是原子的,但是,他们方法的组合并不是原子的 。
不可变类线程安全:String、Integer 等都是不可变类,内部的状态不可以改变,所以方法是线程安全。
像String的replace,substring方法也没有改变内部的状态,这些方法在底层都是新建一个对象,将值复制过去的。
抽象方法如果有参数,被重写后行为不确定可能造成线程不安全,被称之为外星方法:public abstract foo(Student s);
Map<String,Object> map = new HashMap<>(); // 线程不安全
String S1 = "..."; // 线程安全
final String S2 = "..."; // 线程安全
Date D1 = new Date(); // 线程不安全
final Date D2 = new Date(); // 线程不安全,final让D2引用的对象不能变,但对象的内容可以变
3. Monitor
Monitor 被翻译为监视器或管程。每个 Java 对象都可以关联一个 Monitor 对象,Monitor 也是 class,其实例存储在堆中,Monitor这个锁是由操作系统提供的,也被称为重量级锁。synchronized 的重量级锁就是Monitor实现的。
3.1 Java对象头
以32位虚拟机为例,java的对象由两部分组成,第一部分是对象头,第二部分是一些成员变量,普通对象的对象头如下:
其中 Klass Word
指向了对象所属的Class,标明对象是哪个类型,比如Integer,String,Student等等,可以找到类对象。
其中Mark Word
的结构如下:
我们先来看Normal模式的Mark Word,前面25位是一个hashcode,之后4位是表示生代的年龄位数,biased_lock表示是否是偏向锁,最后的01表示其加锁状态。
对于数组对象的话,其除了Mark Word和Klass Word 之外,还有一个数组长度,数组对象结构示意如下:
3.2 Monitor工作流程
Monitor的流程示意图如下:
- 开始时 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中(轻量级锁部分详解)
- 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList BLOCKED(双向链表)
- Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
- 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
- WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制),如下图
注意:
- synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
3.3 字节码角度
比如我们有如下的代码:
public static void main(String[] args) {Object lock = new Object();synchronized (lock) {System.out.println("ok");}
}
编译为字节码,字节码如下:
0: new #2 // new Object
3: dup
4: invokespecial #1 // invokespecial <init>:()V,非虚方法
7: astore_1 // lock引用 -> lock
8: aload_1 // lock (synchronized开始)
9: dup // 一份用来初始化,一份用来引用
10: astore_2 // lock引用 -> slot 2
11: monitorenter // 【将 lock对象 MarkWord 置为 Monitor 指针】
12: getstatic #3 // System.out
15: ldc #4 // "ok"
17: invokevirtual #5 // invokevirtual println:(Ljava/lang/String;)V
20: aload_2 // slot 2(lock引用)
21: monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
22: goto 30
25: astore_3 // any -> slot 3
26: aload_2 // slot 2(lock引用)
27: monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
28: aload_3
29: athrow
30: return
Exception table:from to target type12 22 25 any25 28 25 any
LineNumberTable: ...
LocalVariableTable:Start Length Slot Name Signature0 31 0 args [Ljava/lang/String;8 23 1 lock Ljava/lang/Object;
从字节码中我们可以看出,当使用synchronized加锁的时候,会将对象复制一份用来进行锁的引用,然后就是进行第11到21步的Monitor过程,之后应该就完了,那为什么还会有第22到29步呢?我们可以看到上面有一个异常捕获表,如下:
Exception table:from to target type12 22 25 any25 28 25 any
表示的是第12到22步中如果有错误就跳转到第25到28步。
4. synchronized原理
synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化。
-
synchronized 的可重入表现为同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁,不会因为之前已经获取过还没释放而阻塞,示例如下:
public class ReEntryLockDemo{public static void main(String[] args){final Object objectLockA = new Object();new Thread(() -> {synchronized (objectLockA){System.out.println("-----外层调用");synchronized (objectLockA){System.out.println("-----中层调用");synchronized (objectLockA){System.out.println("-----内层调用");}}}},"t1").start();} }
上面代码的输出为如下:
-----外层调用
-----中层调用
-----内层调用这就是可重入的体现,如果是不可重入锁,那么只会输出
-----外层调用
。 -
synchronized 的不公平表现为synchronized里面的对象会先尝试直接进行上锁,如果上锁失败,才会加入等待队列里面。
-
synchronized 锁的级别如下
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争的增加,只能锁升级,不能降级
锁的级别图示如下,我们可以先看各个级别的锁的原理再来看这幅图。
4.1 轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized,不用关心其具体实现。
可重入锁:线程可以进入任何一个它已经拥有的锁所同步着的代码块,可重入锁最大的作用是避免死锁。
轻量级锁的加锁过程如下:
假设加锁的代码如下:
static final Object obj = new Object();
public static void method1() {synchronized( obj ) {// 同步块 Amethod2();}
}
public static void method2() {synchronized( obj ) {// 同步块 B}
}
-
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的 Mark Word,因为锁住之后对象的Mark Word会指向锁的地址
-
让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录,这里的CAS是比较并交换的意思,这是一个原子操作,可以将其理解为不会被其他线程打断出错的一个比较交换操作,详细原理我们会在后面讲解。
-
如果 CAS 替换成功,对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁
-
如果 CAS 失败,有两种情况:
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是线程自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数
-
当退出 synchronized 代码块(解锁时)
- 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
- 如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
4.2 锁膨胀
在尝试加轻量级锁的过程中,CAS 操作无法成功,可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
-
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
-
Thread-1 加轻量级锁失败,进入锁膨胀流程:
- 为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址
- 然后自己进入 Monitor 的 EntryList BLOCKED
-
当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
4.3 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作,如果重入的次数多了,对效率的影响也会加大,于是产生了偏向锁。
4.3.1 偏向锁过程
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要进行CAS操作。
只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
例如有下面的一个类中的代码:
static final Object obj = new Object();
public static void m1() {synchronized( obj ) {// 同步块 Am2();}
}
public static void m2() {synchronized( obj ) {// 同步块 Bm3();}
}
public static void m3() {synchronized( obj ) {// 同步块 C}
}
上述代码在轻量级锁中的状态如下:
引入偏向锁后,示意图如下:
4.3.2 偏向状态
我们首先来看64位Java虚拟机中的对象头的MarkWord,其结构如下:
当一个对象创建的时候:
-
如果开启了偏向锁(默认开启),那么对象创建后,MarkWord 值为 0x05 即最后 3 位为 101,thread、epoch、age 都为 0
-
偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
-XX:BiasedLockingStartupDelay=0
来禁用延迟。JDK 8 延迟 4s 开启偏向锁原因:在刚开始执行代码时,会有好多线程来抢锁,如果开偏向锁效率反而降低 -
当一个对象已经计算过 hashCode,就再也无法进入偏向状态了,正常状态对象一开始是没有 hashCode 的,第一次调用才生成
-
添加 VM 参数
-XX:-UseBiasedLocking
禁用偏向锁
4.3.3 撤销偏向
偏向锁的撤销有如下三种情况:
- 当一个对象已经计算过 hashCode,就再也无法进入偏向状态了,正常状态对象一开始是没有 hashCode 的,第一次调用才生成,比如一个对象
dog
,其原本是加的偏向锁,调用dog.hashCode()
后,其会失去偏向锁 - 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
- 调用 wait/notify,需要申请 Monitor,进入 WaitSet
4.3.4 批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。
测试代码如下:
@Slf4j(topic = "c.TestBiased")
public class Test1 {public static void main(String[] args) {Vector<Dog> list = new Vector<>();Thread t1 = new Thread(() -> {for (int i = 0; i < 30; i++) {Dog d = new Dog();list.add(d);synchronized (d) {log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}}synchronized (list) {list.notify();}}, "t1");t1.start();Thread t2 = new Thread(() -> {synchronized (list) {try {list.wait();} catch (InterruptedException e) {e.printStackTrace();}}log.debug("===============> ");for (int i = 0; i < 30; i++) {Dog d = list.get(i);log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());synchronized (d) {log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}}, "t2");t2.start();}}class Dog {}
在上面的代码中,我们首先在t1中将30个对象创建并且加入到了list中,然后t2等待list将对象全部加入,此时的30个对象是带有偏向锁的,之后t2调用这30个对象,调用前20个对象的时候,偏向锁转为轻量级锁,之后由于批量重偏向的作用,会将第20个对象及以后的对象都改为偏向锁,所以,t2线程打印出的对象头中,前20个对象的末尾都是001,后10个对象的末尾是101。
4.3.5 批量撤销
如果对象被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。当撤销偏向锁阈值超过 40 次后,JVM 会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
测试如下:
@Slf4j(topic = "c.TestBiased")
public class Test1 {static Thread t1,t2,t3;public static void main(String[] args) throws InterruptedException {test4();}private static void test4() throws InterruptedException {Vector<Dog> list = new Vector<>();int loopNumber = 39;t1 = new Thread(() -> {for (int i = 0; i < loopNumber; i++) {Dog d = new Dog();list.add(d);synchronized (d) {log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}}LockSupport.unpark(t2);}, "t1");t1.start();t2 = new Thread(() -> {LockSupport.park();log.debug("===============> ");for (int i = 0; i < loopNumber; i++) {Dog d = list.get(i);log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());synchronized (d) {log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}LockSupport.unpark(t3);}, "t2");t2.start();t3 = new Thread(() -> {LockSupport.park();log.debug("===============> ");for (int i = 0; i < loopNumber; i++) {Dog d = list.get(i);log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());synchronized (d) {log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());}}, "t3");t3.start();t3.join();log.debug(ClassLayout.parseInstance(new Dog()).toPrintable());}
}class Dog {}
上面的示例中,创建对象的个数达到了39个,这里使用了unpack和pack来进行线程的等待,在t2中使用 LockSupport.park()
等待t1的39个对象创建完成,之后t2调用其中的对象,然后前19个对象进行偏向锁转为轻量级锁,偏向锁撤销了19次,之后的19个对象由于批量重偏向的作用,重新加上偏向锁。然后t3调用list中的对象时由于,前19个对象已经是轻量级锁了,无需操作,后面20个对象由于批量重偏向膨胀成轻量级锁,一共撤销了39次偏向锁,最后重新new一个对象的时候,是第40次,此时触发批量撤销,因此,最后new出来的对象是轻量级锁。
5. synchronized优化
5.1 自旋锁
重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁
注意:
- 自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势
- 自旋失败的线程会进入阻塞状态
优点:不会进入阻塞状态,减少线程上下文切换的消耗
缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源
自旋锁情况:
-
自旋成功的情况:
-
自旋失败的情况:
自旋锁说明:
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
- Java 7 之后不能控制是否开启自旋功能,由 JVM 控制
自旋锁实现如下:
//手写自旋锁
public class SpinLock {// 泛型装的是Thread,原子引用线程AtomicReference<Thread> atomicReference = new AtomicReference<>();public void lock() {Thread thread = Thread.currentThread();System.out.println(thread.getName() + " come in");//开始自旋,期望值为null,更新值是当前线程while (!atomicReference.compareAndSet(null, thread)) {Thread.sleep(1000);System.out.println(thread.getName() + " 正在自旋");}System.out.println(thread.getName() + " 自旋成功");}public void unlock() {Thread thread = Thread.currentThread();//线程使用完锁把引用变为nullatomicReference.compareAndSet(thread, null);System.out.println(thread.getName() + " invoke unlock");}public static void main(String[] args) throws InterruptedException {SpinLock lock = new SpinLock();new Thread(() -> {//占有锁lock.lock();Thread.sleep(10000); //释放锁lock.unlock();},"t1").start();// 让main线程暂停1秒,使得t1线程,先执行Thread.sleep(1000);new Thread(() -> {lock.lock();lock.unlock();},"t2").start();}
}
5.2 锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM 即时编译器的优化
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)
比如下面的代码:
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations=3)
@Measurement(iterations=5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MyBenchmark {static int x = 0;@Benchmarkpublic void a() throws Exception {x++;}@Benchmark// JIT 即时编译器public void b() throws Exception {Object o = new Object();synchronized (o) {x++;}}
}
上面的代码其实需不需要锁都行,JIT就会对上面的代码编译进行优化,将锁给去掉,提高程序的执行效率。
5.3 锁粗化
对相同对象多次加锁,导致线程发生多次重入,频繁的加锁操作就会导致性能损耗,可以使用锁粗化方式优化
如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部
-
一些看起来没有加锁的代码,其实隐式的加了很多锁:
public static String concatString(String s1, String s2, String s3) {return s1 + s2 + s3; }
-
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,转化为 StringBuffer 对象的连续 append() 操作,每个 append() 方法中都有一个同步块
public static String concatString(String s1, String s2, String s3) {StringBuffer sb = new StringBuffer();sb.append(s1);sb.append(s2);sb.append(s3);return sb.toString(); }
扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,只需要加锁一次就可以
5.4 多把锁
比如一间大屋子有两个功能睡觉、学习,互不相干。现在一个人要学习,一个人要睡觉,如果只用一间屋子,那么一个人学习的时候另一个人就不能睡觉,一个人睡觉的时候另一个人就不能学习,并发度是很低的。
解决问题的方法就是划分多个房间,比如将一间屋子划分为书房和卧室,睡觉的人分去卧室,学习的人去书房就行,这就是将锁的粒度细分。
将锁的粒度细分:
- 好处,是可以增强并发度
- 坏处,如果一个线程需要同时获得多把锁,就容易发生死锁
代码示例如下,即像下面的代码一样分为两个对象分别上锁:
public static void main(String[] args) {BigRoom bigRoom = new BigRoom();new Thread(() -> { bigRoom.study(); }).start();new Thread(() -> { bigRoom.sleep(); }).start();
}
class BigRoom {private final Object studyRoom = new Object();private final Object sleepRoom = new Object();public void sleep() throws InterruptedException {synchronized (sleepRoom) {System.out.println("sleeping 2 小时");Thread.sleep(2000);}}public void study() throws InterruptedException {synchronized (studyRoom) {System.out.println("study 1 小时");Thread.sleep(1000);}}
}