synchronized关键字
关于多线程,主要问题就是如何控制对共享资源的访问。如何保证多个线程访问的数据是一致的,即保证数据的一致性成为多线程编程必须要解决的问题。在Java中,如果多个线程对一个方法或者代码块中的共享变量进行访问,可以使用synchronized关键字。下面通过一个实例开始对synchronized关键字进行分析。在这个例子中,有一个同步类方法、一个同步实例方法、同步类代码块方法,同步实例代码块方法。
//javac -d .\classes SyncrhonizedTest.java
//E:\muke\code\classes>javap -v -s -c -l SyncrhonizedTest.class > SyncrhonizedTest.txt
public class SyncMethod {
public static int i;
//(1)同步实例方法
public synchronized void syncMethod() {
i++;
}
//(2)同步类方法
public synchronized static void syncStaticMethod() {
i++;
}
//(3)同步实例代码块方法
public void syncCodeSnipt() {
synchronized (this) {
i++;
}
}
//(4)同步类代码块方法
public static void syncStaticCodeSnipt() {
synchronized (SyncMethod.class) {
i++;
}
}
}
synchronized基础
在上面的例子中,我们需要注意的是synchronized关键字只有这4种用法。分别是同步实例方法、同步类方法、同步实例代码块,同步类代码块4种情况。以下用法是不允许的。
- 在定义接口方法时不能使用synchronized关键字。
- 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。
对象锁及类锁
对象锁
对象锁,锁的是对象。对象锁有两种情况,同步实例方法锁,同步实例代码块锁。
同步实例方法
线程执行对象中synchronized同步方法和synchronized(this)代码块时呈现同步效果。如示例中(1)。
如果两个线程必须使用了同一个"对象监视器",运行结果同步,否则不同步。
同步实例代码块
当一个对象访问synchronized(this)代码块时,其他线程对同一个对象中所有其他synchronized(this)代码块代码块的访问将被阻塞,这说明synchronized(this)代码块使用的"对象监视器"是同一个。即synchronized(this)代码块是锁定当前对象的。如示例中(3)。
类锁
同步类方法锁
静态同步方法是在方法所属的类对象(如MyClass.class)上同步。由于每个类只有一个类对象存在于JVM堆中(JDK1.8),只有一个类级别的锁,只允许同时只有一个线程能够进入到同一个类的静态同步方法中。如示例中(2)。
同步类代码块方法
静态方法中出现同步代码块,也是在类对象上同步。如示例中的(4)。
通过对上面的分析,可以得出以下表中的结论。
synchronized性质
synchronized属于独占式的悲观锁,同时属于不可中断重入锁。
可重入性
在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。
- 同一个方法中是可重入的。就好比是递归调用同步方法。
- 不同的方法是可重入的。就好比是一个同步方法调用另外一个同步方法。
- 不同的类方法,在不同类之间是可重入的。
synchronized关键字是如何保证可重入的呢?其工作是由jvm基于原子性的内部锁机制来完成的,线程第一次给对象加锁的时候,计数为1,以后这个线程再次获取锁的时候,计数会依次增加。同理,任务离开的时候,相应的计数器也会减少。这个与后面的AQS的state属性设计类似。
不可中断性质
不可中断的意思你可以这样理解,别人在拿电脑打游戏,你必须等待别人不想玩了你才能打游戏。别人即使在电脑前吃泡面,你也得等他不想完了,让出电脑才能打游戏。在Java中,那个人占用了电脑可以比作占用了锁,你必须等别人释放了锁,你才能可以拿到。可以看出,如果人家在吃泡面,你还得等待。别人吃泡面,你还不能中断。这个是synchronized的弊端。
中断两种情况,一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位,另外一种是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动调用isInterrupted()判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)。
线程通过方法isInterrupted()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()对当前线程的中断标识位进行复位。
有一点需要注意:如果线程已经处于终结状态,即使该线程被中断过,在调用该线程对象的isInterrupted()时依旧返回false。
synchronized内部原理
JDK1.8反编译上面的例子得到如下的字节码。
public synchronized void syncMethod();
Signature: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
LineNumberTable:
line 10: 0
line 11: 8
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: return
LineNumberTable:
line 10: 0
line 11: 8
public static synchronized void syncStaticMethod();
Signature: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
LineNumberTable:
line 15: 0
line 16: 8
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: return
LineNumberTable:
line 15: 0
line 16: 8
public void syncCodeSnipt();
Signature: ()V
flags: ACC_PUBLIC
LineNumberTable:
line 20: 0
line 21: 4
line 22: 12
line 23: 22
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field i:I
7: iconst_1
8: iadd
9: putstatic #2 // Field i:I
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
LineNumberTable:
line 20: 0
line 21: 4
line 22: 12
line 23: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 17
locals = [ class SyncMethod, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public static void syncStaticCodeSnipt();
Signature: ()V
flags: ACC_PUBLIC, ACC_STATIC
LineNumberTable:
line 27: 0
line 28: 6
line 29: 14
line 30: 24
Code:
stack=2, locals=2, args_size=0
0: ldc_w #3 // class SyncMethod
3: dup
4: astore_0
5: monitorenter
6: getstatic #2 // Field i:I
9: iconst_1
10: iadd
11: putstatic #2 // Field i:I
14: aload_0
15: monitorexit //多个monitorexit出口
16: goto 24
19: astore_1
20: aload_0
21: monitorexit //多个monitorexit出口
22: aload_1
23: athrow
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
line 27: 0
line 28: 6
line 29: 14
line 30: 24
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
通过反编译的代码可发现,四种同步类型的同步实现方式并不是相同的。如下表所示。
方法类型 | 实现思路 |
---|---|
同步实例方法 | 基于标志ACC_SYNCHRONIZED实现 |
同步类方法 | 基于标志ACC_SYNCHRONIZED实现 |
同步实例代码块方法 | 基于monitorenter与monitorexit指令实现 |
同步类代码块方法同步实例代码块方法 | 基于monitorenter与monitorexit指令实现 |
同步实例方法和同步类方法是通过ACC_SYNCHRONIZED实现的。方法级的同步是隐式,即无需通过字节码指令来控制的,它在方法调用和返回操作之中实现。即JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
同步实例代码块方法和同步类代码块方法是通过字节码指令monitorenter和monitorexit实现的。synchronized的执行严格遵守java happens-before 规则,一个monitor exit指令之前必定要有一个monitor enter。一个monitor enter根据不同分支可以对应多个monitor exit。它能够保证在任何时候任何线程执行到monitor enter成功之前都必须从主内存中获取数据,而不是缓存中,在monitor exit运行成功之后,共享变量被更新后的值必须刷入主内存。JVM怎么保证这些操作的呢,我们将从Java对象说起。
理解Java对象头与Monitor
Java对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示。
其中2个字的对象头是实现synchronized锁对象的基础。它分为两个部分,一个是用于存储对象自身运行时数据的Mark Word。另一个是对象指向它的类元素的指针。如果对象是一个Java数组,那么对象头中还有一个字的长度是用于记录数组长度的。32位HotSpot虚拟机对象头Mark Word内容如下。其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位,垃圾回收标记,年龄等。以下是32位JVM的Mark Word默认存储结构。
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
重量级锁
在上图中,其中轻量级锁和偏向锁是Java 6对synchronized锁进行优化后新增加的,稍后我们再分析。我们先分析一下重量级锁,重量级锁也称为Java的内置锁,每个对象有一个内置锁或者叫做监视器(monitor),只允许一个线程获取该锁。当线程B访问某个对象的synchronized方法时拥有该对象锁时,该对象头的重量级锁标识位为10,且对象头中的重量级锁的指针指向互斥量monitor对象(也称为管程或监视器锁)的起始地址。当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个锁,如果B线程不执行完毕或抛出异常释放这个锁,那么A线程将永远等待下去,即线程A将进入BLOCKED状态。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。
前面说过的对象锁和类锁的区别是,对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。我们知道,类的实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的。
Monitor
monitor机制的主要目的是为了互斥进入临界区,为了做到能够阻塞无法进入临界区的线程,需要monitor Object 来协助,在HotSpot中,是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件中)
ObjectMonitor() {
_header = NULL;//markOop对象头
_count = 0; //计数器
_waiters = 0,//等待线程数
_recursions = 0;//重入次数
_object = NULL;
_owner = NULL;//指向获得ObjectMonitor对象的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;//JVM为每个尝试进入synchronized代码段的JavaThread创建一个ObjectWaiter并添加到_cxq队列中
FreeNext = NULL ;
_EntryList = NULL ; //处于等待block状态的线程,会被加入到该列表。由ObjectWaiter组成的双向链表。JVM会从该链表中取出一个ObjectWaiter并唤醒对应的JavaThread
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;//监视器前一个拥有者的线程id
}
ObjectMonitor中有两个队列,_WaitSet和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示。
当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 monitor 机制中称为条件变量。
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析)。
从上面的例子可以看出,synchronized关键字在使用时,需要指定一个对象与其关联。例如synchronized(this),或者 synchronized(Class.class),synchronized 如果修饰的是实例方法,那么其关联的对象实际上是 this,如果修饰的是类方法,那么其关联的对象是 this.class。synchronzied关联的对象就是 monitor object。当我们想要用monitor时,必须是该语言支持monitor原语。如C语言是不支持monitor的,而Java语言支持。关于Java如何实现monitor机制,则由编译器和Java语言本身配合完成。
monitor 的重要特点是,同一个时刻,只有一个 进程/线程 能进入 monitor 中定义的临界区,这使得 monitor 能够达到互斥的效果。但仅仅有互斥的作用是不够的,无法进入 monitor 临界区的 进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。显然,monitor 作为一个同步工具,也应该提供这样的管理 进程/线程 状态的机制。想想我们为什么觉得 semaphore 和 mutex 在编程上容易出错,因为我们需要去亲自操作变量以及对 进程/线程 进行阻塞和唤醒。monitor 这个机制之所以被称为“更高级的原语”,那么它就不可避免地需要对外屏蔽掉这些机制,并且在内部实现这些机制,使得使用 monitor 的人看到的是一个简洁易用的接口。
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
原理剖析
上面的例子是说明This monitor和Class monitor两种情况,当使用synchronized关键字同步类的不同实例方法,争抢的是同一个monitor的锁。与之相关联的是引用则是ThisMonitor的实例引用。这里应该有问题。当我们得出synchronized同步某个类的不同静态方法争抢的也是同一个类的monitor的锁。
使用synchronized需要注意的地方
- 与monitor关联的对象不能为空。
private final Object object = null;
-
synchronized作用域太大。
synchronized作用域越大,对共享资源占用的时候越长。一定程度上代表其效率越低。
-
不同的monitor需要获取同一个对象。
很多初学者并不注意并发的时候需要锁同一个对象,锁才有效。
-
多个锁的交叉导致死锁。
多个锁嵌套控制很容易出现死锁,对于死锁的检测将在接下来讲解。
死锁检测
死锁产生的原因主要有以下几种。
-
交叉锁。
当线程T1持有O1的锁等待获取O2的锁。而线程T2持有O2的锁等待获取O1的锁。
-
内存不足
两个线程T1和T2,都在等待对方线程内存的释放。
-
数据库锁
-
文件锁
死锁检测
Synchronized是非公平锁。Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋CAS获取锁,如果获取不到就进入ContentionList中。
JVM锁分类
JDK1.6中引入了自旋锁、偏向锁、轻量级锁和重量级锁的概念。Java中锁的状态分为四种,无锁状态、偏向锁、轻量级锁和重量级锁,然而都不是Java语言层面的锁优化方法。随着锁的竞争,锁优化的流程应该为:检查共享资源是否冲突,如果冲突,则自旋等待,如果还不行则依次为使用轻量级锁,重量级锁。锁的升级是单向的,只能从低到高升级。这样自适用使用锁才能真正减少重量级加锁。如果没有竞争,则直接使用或者测试偏向锁后再使用。在高并发情况下,JDK1.7之前的版本可通过-XX:-UseBiaseLocking来禁止偏向优化反而会提升性能,JDK1.7之后去掉了该参数,由JVM自己决定。前面我们已分析过重量级锁,下面我们将简单介绍偏向锁和轻量级锁以及JVM的其他优化手段。
内置于JVM中的获取锁的优化方法和获取锁的步骤
-
偏向锁可用则会先尝试偏向锁
-
轻量级锁可用则会先尝试轻量级锁
-
以上都失败,尝试自旋锁
-
再失败,尝试普通锁,使用OS互斥量在操作系统层挂起。
偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,大部分情况下锁是没有竞争的,且总是由同一线程多次获取。为了减少同一线程获取锁(会涉及到一些CAS操作)的代价而引入偏向锁。偏向锁会偏向于当前已经持有锁的线程,持有偏向锁的线程将永远不需要再进行同步。下图是偏向锁的获取和撤销过程。
偏向锁加锁过程
如果某个线程获得了锁,那么锁就进入偏向模式,会在对象头Mark和栈帧中的锁记录里存储锁偏向的线程ID,此时对象头Mark Word的结构也变为偏向锁结构。以后当这个线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁等同步操作,只需要简单地测试下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是否偏向锁),如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。-XX:+UseBiasedLocking JDK1.6中默认为启用。JDK1.7后去掉该参数而由JVM控制。
偏向锁的释放
当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,偏向模式结束。偏向锁的撤销,需要等待全局安全点,没有正在执行的字节码,就是Stop the World。
在竞争激烈的场合,偏向锁会增加系统负担。因为一个线程刚拿到锁,偏向模式可能就结束了。而导致系统不必要的操作浪费。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。
轻量级锁
倘若偏向锁失败,普通锁处理性能不够理想,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。轻量级锁并不是用来代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁是在无竞争的情况下使用CAS(Compare-and-Swap)操作去消除同步使用的互斥量。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
加锁过程
在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Work的拷贝(即Displaced Mark Work),这时候线程堆栈与对象头的状态如下图所示。
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record指针,并将Lock record里的owner指针指向object mark word。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态下图所示。
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁(常规锁),锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
解锁过程
如果对象的Mark Work仍然指向着线程的锁记录,就通过CAS操作尝试把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。如果替换成功,整个同步过程就完成了。 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
同偏向锁一样,在没有锁竞争的前提下,减少传统锁使用OS互斥量产生的性能损耗。在竞争激烈时,轻量级锁会多做很多额外操作,导致性能下降。判断一个线程是否持有轻量级锁,只要判断对象头的指针,是否在线程的栈空间范围内。
自旋锁与自适用锁
偏向锁和轻量级锁表示资源不存在竞争,如果存在竞争呢,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),(自旋本身也耗CPU处理时间)。挂起和恢复线程的操作都需要转入内核态。一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,自旋失败,会降低系统性能,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,如果同步块很短,自旋成功率大大增加,节省线程挂起切换时间,提升系统性能,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
JDK1.6中-XX:+UseSpinning开启,1.6之前自旋次数的默认值是10次,超过十次按照传统方式挂起线程。可以使用参数-XX:PreBlockSpin来更改。1.7之后去掉此参数,改为内置实现,即是自适用自旋。即:自旋的时间由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
在AQS中使用自旋,是为了尽量在Java级别实现自旋等待获取锁,而不切换到核心态调用操作系统的api阻塞线程。在并发量不是很大时,因每次自旋就可以获取锁执行,自旋将会比阻塞线程高效。但是如果并发量很大,使得自旋获取锁的机会减少,自旋相当于做无效操作。还不如直接将线程切换到核心态将线程挂起。在深入理解JVM书中,谈到自旋锁,因为锁的堵塞释放对于cpu资源的损害很高,那么自旋锁就是当线程A访问共享资源的时候,其他线程并不放弃对锁的持有,它们在不停循环,不断尝试的获取锁,直到获得锁就停止循环,自旋锁是对于资源共享的一种优化手段,但是它适用于对锁持有时间比较短的情况。
锁优化
- 减少锁的持有时间
持有时间长,自旋就容易失败,自旋失败就会引起锁升级。这里与数据库事务一样,事务开启时间越长,越容易耗尽数据库资源。而数据库时间越长,也越容易耗尽Tomcat容器资源。
public synchronized void syncMethod(){
othercode1();
mutextMethod();
othercode2();
}
可换成:
public void syncMethod(){
othercode1();
synchronized(this){
mutextMethod();
}
othercode2();
}
- 减小锁粒度
粒度大,竞争激烈,偏向锁,轻量级锁失败概率就高。可以将大对象拆成小对象,大大增加并行度,降低锁竞争,提高偏向锁,轻量级锁的成功率。如JDK源码中的ConcurrentHashMap,通过减少锁的粒度,ConcurrentHashMap就允许若干个线程同时进入。
还有HashMap的同步实现方法Collections.synchronizedMap(Map<K,V>m)返回SynchronizedMap对象。
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
- 锁分离
对文件读取时,允许多个线程同时进入。锁竞争更多的时候是防止并发写的问题。可以根据功能进行锁分离,针对读多写少的情况,可以提高性能(对文件读取时,允许多个线程同时进入),可以使用ReadWriteLock。
根据读写分离思想可以延伸,只要操作互不影响,锁就可以分离。如JDK源码中的链表阻塞队列LinkedBlockingQueue。从功能的角度做分离,功能不同,互补影响,就可以分离。LinkedBlockingQueue实现中,可以使用takeLock和putLock两个锁。take只作用于前端,put只作用于尾端 ,E入队时,只要将D.last=E,A出队时,只要head=head.next
锁粗化
通常情况下,为了保证多线程之间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,凡事都有个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。
for(int i = 0; i < 100; i++) {
synchronized (LockEliminate.class) {
//TODO
}
}
//可转换为下面这种
synchronized (LockEliminate.class) {
for(int i = 0; i < 100; i++) {
//TODO
}
}
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
在即时编译时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。怎么发现?通过逃逸分析技术。如果对一段代码发现堆上的数据都不会逃逸出去从而被其他线程访问到,就可以把他们当做栈上数据,认为是线程私有。同步加锁自然也就无须进行。
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
//以下是StringBuffer的append方法源码
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
如上面的代码所示,锁不是由程序引入的,JDK自带的(StringBuffer)一些库,可能内置锁。每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象,且它的动态作用域被限制在concatString内。sb为栈上对象,不会被全局访问的,没有必要加锁。
无锁
锁控制是悲观的操作方式,而无锁是对共享资源乐观的操作方式。主要有两种方式。
-
CAS(Compare And Swap)
CAS算法的过程是这样:它包含3个参数CAS(V,E,N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即时没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
CAS是Compare-And-Swap,为比较并且交换。是利用了操作系统的原子操作指令,x86上的指令为cmpxchgl,安全,AQS中用于修改状态值state,AQS的队列头尾指针修改等。
-
非阻塞的同步
总结
在使用synchronized关键字时候,应该尽可能避免在synchronized方法或synchronized块中使用sleep或者yield方法,因为synchronized程序块占有着对象锁,你休息那么其他的线程只能一边等着你醒来执行完了才能执行。不但严重影响效率,也不合逻辑。
同样,在同步程序块内调用yeild方法让出CPU资源也没有意义,因为你占用着锁,其他互斥线程还是无法访问同步程序块。当然与同步程序块无关的线程可以获得更多的执行时间。
jdk1.8源码中哪些地方使用了synchronized关键字?改进后的synchronized关键字效率。synchronized与可重入锁ReentrantLock。
这里就使用同步机制获取互斥锁的情况,进行几点说明:
- 如果同一个方法内同时有两个或更多线程,则每个线程有自己的局部变量拷贝。
- 类的每个实例都有自己的对象级别锁。当一个线程访问实例对象中的 synchronized 同步代码块或同步方法时,该线程便获取了该实例的对象级别锁,其他线程这时如果要访问 synchronized 同步代码块或同步方法,便需要阻塞等待,直到前面的线程从同步代码块或方法中退出,释放掉了该对象级别锁。
- 访问同一个类的不同实例对象中的同步代码块,不存在阻塞等待获取对象锁的问题,因为它们获取的是各自实例的对象级别锁,相互之间没有影响。
- 持有一个对象级别锁不会阻止该线程被交换出来,也不会阻塞其他线程访问同一示例对象中的非 synchronized 代码。当一个线程 A 持有一个对象级别锁(即进入了 synchronized 修饰的代码块或方法中)时,线程也有可能被交换出去,此时线程 B 有可能获取执行该对象中代码的时间,但它只能执行非同步代码(没有用 synchronized 修饰),当执行到同步代码时,便会被阻塞,此时可能线程规划器又让 A 线程运行,A 线程继续持有对象级别锁,当 A 线程退出同步代码时(即释放了对象级别锁),如果 B 线程此时再运行,便会获得该对象级别锁,从而执行 synchronized 中的代码。
- 持有对象级别锁的线程会让其他线程阻塞在所有的 synchronized 代码外。例如,在一个类中有三个synchronized 方法 a,b,c,当线程 A 正在执行一个实例对象 M 中的方法 a 时,它便获得了该对象级别锁,那么其他的线程在执行同一实例对象(即对象 M)中的代码时,便会在所有的 synchronized 方法处阻塞,即在方法 a,b,c 处都要被阻塞,等线程 A 释放掉对象级别锁时,其他的线程才可以去执行方法 a,b 或者 c 中的代码,从而获得该对象级别锁。
- 使用 synchronized(obj)同步语句块,可以获取指定对象上的对象级别锁。obj 为对象的引用,如果获取了 obj 对象上的对象级别锁,在并发访问 obj 对象时时,便会在其 synchronized 代码处阻塞等待,直到获取到该 obj对象的对象级别锁。当 obj 为 this 时,便是获取当前对象的对象级别锁。
- 类级别锁被特定类的所有示例共享,它用于控制对 static 成员变量以及 static 方法的并发访问。具体用法与对象级别锁相似。
- 互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。synchronized 关键字经过编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁,如果获得了锁,把锁的计数器加 1,相应地,在执行 monitorexit 指令时会将锁计数器减 1,当计数器为 0 时,锁便被释放了。由于 synchronized 同步块对同一个线程是可重入的,因此一个线程可以多次获得同一个对象的互斥锁,同样,要释放相应次数的该互斥锁,才能最终释放掉该锁。
https://blog.csdn.net/javazejian/article/details/72828483
https://createchance.github.io/post/java-并发之基石篇/