“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下 的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的 ”—— 《Java并发编程实战(Java Concurrency In Practice)》
互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些, 当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量 (Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
在Java里面,最基本的互斥同步手段就是synchronized关键字 。
synchronized的包含三个特性:
1、在方法中使用synchronized关键字实现同步的原因是使用了flag标记ACC_SYNCHRONIZED,在调用方法时,调用指令回去检查方法的ACC_SYNCHRONIZED访问标志是否设置,如果设置了,执行线程先持有同步锁,然后执行方法,最后在方法完成时候释放锁。
测试代码:
/**
* @Description:
* @Author: zhouzhou
*/
public class Test {
synchronized public static void testMethod(){
}
public static void main(String[] args) {
testMethod();
}
}
javap -c -v Test.class
javap: 将class文件转为字节码文件。
-c:对代码进行反汇编。
-v:输出附加信息。
指令的核心代码如下:
public static synchronized void testMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 11: 0
我们发现在反编译的字节码指令中,testMethod()方法使用了 flag标记 ACC_STATIC, ACC_SYNCHRONIZED,说明此方法是同步的。
2、在synchronized代码块中则使用的是monitorenter和monitorexit指令进行同步处理。
测试代码如下:
/**
* @Description:
* @Author: zhouzhou
*/
public class Test {
public void testMethod(){
synchronized(this){
int i = 10;
}
}
public static void main(String[] args) {
Test test = new Test();
test.testMethod();
}
}
使用javap命令。
public void testMethod();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: bipush 10
6: istore_2
7: aload_1
8: monitorexit
9: goto 17
12: astore_3
13: aload_1
14: monitorexit
15: aload_3
16: athrow
17: return
synchronized关键字经过Javac编译之后,会在同步块的前后分别形成 monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
从功能上看,根据以上《Java虚拟机规范》对monitorenter和monitorexit的行为描述,我们可以得出两个关于synchronized的直接推论,这是使用它时需特别注意的:
从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作。在主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被synchronized修饰的getter() 或setter()方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。因此才说, synchronized是Java语言中一个重量级的操作,有经验的程序员都只会在确实必要的情况下才使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程, 以避免频繁地切入核心态之中。
synchronized的3种使用方式:
public class MyService {
synchronized public static void testMethod1(){
}
public void testMethod2(){
synchronized (MyService.class){
}
}
synchronized public void testMethod3(){
}
synchronized public void testMethod4(){
synchronized (this){
}
}
public void testMethod5(){
synchronized ("abc"){
}
}
}
上面代码出现了三种类型的锁的对象:
(1)testMethod1()和testMethod2()持有的锁是同一个,即MyService.java对应的Class类的对象。
(2)testMethod3()和testMethod4()持有的锁是同一个,即MyService.java类的对象。
(3)testMethod5()持有的锁是字符串"abc"。
说明:testMethod1()和testMethod2()是同步关系,testMethod3()和testMethod4()是同步关系。
关键字synchronized持有冲入锁的功能,即在使用synchronized时,当一个线程得到一个对象锁后,再次请求此对象锁时是可以得到该对象锁的,这也证明在一个synchronized方法/块内部调用本类的其他synchronized方法/块时,是永远可以得到锁的。
/**
* @Description:
* @Author: zhouzhou
* 2022/10/13 13:51
*/
public class Service {
synchronized public void service1(){
System.out.println("service1");
service2();
}
synchronized public void service2(){
System.out.println("service2");
service3();
}
synchronized public void service3() {
System.out.println("service3");
}
}
public class Test {
public static void main(String[] args) {
Thread thread = new Thread(()->{
new Service().service1();
});
thread.start();
}
}
输出结果:
service1
service2
service3
“可重入锁”是指自己可以再次获取自己的内部锁。例如,一个线程获了某个对象锁,此时这个对象锁还没有释放,当其再次想要获取这个对象锁时,还是可以获取的,如果是不可重入锁,则方法service2()不会被调用,则service3()更不会被调用。同时,锁重入也支持父子类继承的环境:当存在父子类继承关系的时候,子类完全可以通过锁重入调用父类的同步方法。
注:关键字synchronized取得的锁都是对象锁,而不是把一段代码或者方法当作锁
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用**CAS(compare and swap)**又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程
CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。
Synchronized和CAS比较:元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的间线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。
在J.U.C包中利用CAS实现类有很多,可以说是支撑起整个concurrency包的实现,在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现。
1. ABA问题 因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。java这么优秀的语言,当然在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。
2. 自旋时间过长
使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。
3. 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。
在同步的时候是获取对象的monitor,即获取到对象的锁。对象的锁无非就是类似对象的一个标志,那么这个标志就是存放在Java对象的对象头。
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
偏向锁也是JDK 6中引入的一项锁优化措施 。 偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线 程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
偏向锁的获取
当一个线程访问同步块并获取锁时 ,虚拟机将会把对象头中的标志 位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程 的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系 统互斥量产生的性能消耗。
**轻量级锁的加锁过程: **
1、线程在进入到同步代码块的时候,JVM 会先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象当前 Mark Word 的拷贝(官方称为 Displaced Mark Word),owner 指针指向对象的 Mark Word。此时堆栈与对象头的状态如图所示:
2、JVM 使用 CAS 操作尝试将对象头中的 Mark Word 更新为指向 Lock Record 的指针。如果更新成功,则执行步骤3;更新失败,则执行步骤4
3、如果更新成功,那么这个线程就拥有了该对象的锁,对象的 Mark Word 的锁状态为轻量级锁(标志位转变为’00’)。此时线程堆栈与对象头的状态如图所示:
4、如果更新失败,JVM 首先检查对象的 Mark Word 是否指向当前线程的栈帧
轻量级锁的解锁过程:
1、通过 CAS 操作用线程中复制的 Displaced Mark Word 中的数据替换对象当前的 Mark Word
2、如果替换成功,整个同步过程就完成了
3、如果替换失败,说明有其他线程尝试过获取该锁,那就在释放锁的同时,唤醒被挂起的线程
偏向锁性能最好,避免了 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。
自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开 启,在JDK 6中就已经改为默认开启了 。同时在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题。自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。自旋的持续时间是变化的,自旋锁变 “聪明” 了。比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。
阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。但是自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理 器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限 度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次 。
public class Person {
private String name;
private int age;
public Person(String personName, int personAge) {
name = personName;
age = personAge;
}
public Person(Person p) {
this(p.getName(), p.getAge());
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
class Employee {
private Person person;
public Person getPerson() {
return new Person(person);
}
public void printEmployeeDetail(Employee emp) {
Person person = emp.getPerson();
System.out.println("Employee's name: " + person.getName() + "; age: " + person.getAge());
}
}
在这段代码中,我们看到下方的 Employee 类中的 getPerson() 方法,这个方法中使用了类里面的person 对象,并且新建一个和它属性完全相同的新的 person 对象,目的是防止方法调用者修改原来的 person 对象。但是在这个例子中,其实是没有任何必要新建对象的,因为我们的printEmployeeDetail() 方法没有对这个对象做出任何的修改,仅仅是打印,既然如此,我们其实可以直接打印最开始的 person 对象,而无须新建一个新的。
如果编译器可以确定最开始的 person 对象不会被修改的话,它可能会优化并且消除这个新建 person的过程。根据这样的思想,接下来我们就来举一个锁消除的例子,,经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。
例如,我们的 StringBuffffer 的 append 方法如下所示:
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
释放了锁,紧接着什么都没做,又重新获取锁
public void lockCoarsening() {
synchronized (this) {
}
synchronized (this) {
}
synchronized (this) {
}
}
那么其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个synchronized 块合并为一个较大的同步块。这样做的好处在于在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。
不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。如果在循环中我们也这样做,如代码所示:
for (int i = 0; i < 1000; i++) {
synchronized (this) {
}
}
也就是我们在第一次循环的开始,就开始扩大同步区域并持有锁,直到最后一次循环结束,才结束同步代码块释放锁的话,这就会导致其他线程长时间无法获得锁。所以,这里的锁粗化不适用于循环的场景,仅适用于非循环的场景。
锁粗化功能是默认打开的,用 -XX:-EliminateLocks可以关闭该功能
参考:https://juejin.cn/post/7145817289730818062