JAVA并发(四)JMM、CAS、原子性、可见性、有序性、synchronized对象锁、JVM锁优化

线程安全的定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者线程如何交替执行,主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类为线程安全的。

对于线程安全的理解,更简便的一种方式是:能否保证原子性、有序性、可见性。原子性简单理解就是:多线程下的对变量的访问操作也是原子的,不会中途被其他线程篡改。有序性的简单理解是:多线程下代码的执行顺序是正确的,不会因为指令重排序的存在,导致多次的执行结果不同。可见性的简单理解是:多线程下某个线程对变量的修改,其他线程能够看到这种修改。

本文将从缓存与内存之间数据安全传递的问题入手,介绍现代处理器的缓存一致性协议,然后介绍JMM工作内存、主内存、以及二者之间数据的安全传递是如何保证的,进而详细介绍原子性、可见性、有序性;在介绍原子性时,顺便介绍CAS,最后单独一章介绍JAVA对象锁-----synchronized关键字的原理、使用、以及JVM的锁优化。

文章目录

  • JAVA内存模型
    • 缓存与内存
    • 缓存一致性协议
    • 内存模型JMM
      • 与JVM堆栈的对应关系
      • 工作内存与主内存交互操作
  • 可见性
    • volatile原理
      • 回写内存
      • 缓存一致性协议
      • 可保证有序性
    • volatile使用场景
  • 有序性
    • 解决方案
  • 原子性
    • 竞态条件
    • 避免竞态条件---复合操作
    • 乐观锁CAS
      • ABA问题
      • 自旋时间长开销大
      • 只能保证一个共享变量原子操作
  • 多个复合操作存在的问题
  • 对象锁
    • synchronized的原理
    • synchronized的使用
    • JVM的锁优化
      • 轻量级锁
      • 偏向锁
      • 自旋锁
      • 自旋自适应锁
      • 锁消除
      • 锁粗化
    • 性能提升原则
  • 小结

JAVA内存模型

本章参考书籍《深入理解JVM》

缓存与内存

cpu处理速度远大于从内存读写数据的速度,因此,现在的计算机都会在cpu与内存之间加一层高速缓存区,将运算需要的数据从内存复制到缓冲中,让运算快速进行,运算结束后,再从缓存同步回内存,处理器无需等待内存读写。

缓存带来了缓存一致性的问题,在多处理器中,每个处理器都有自己的高速缓存区,而这些处理器又共享同一块内存,当多个处理器的运算任务涉及到同一块内存时,将可能导致缓存数据不一致,同步回内存的数据究竟以哪个缓存为准?
JAVA并发(四)JMM、CAS、原子性、可见性、有序性、synchronized对象锁、JVM锁优化_第1张图片

缓存一致性协议

缓存一致性协议不是JMM规定的,而是处理器厂商共同制定的,作用在处理器上。

在多处理器下,为了保证各个处理器的缓存是一致的,就会在各个处理器实现缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,如果处理器发现自己缓存行对应的内存地址被修改,就会将该缓存行设置成无效状态,下次要使用就会重新从内存中读取。

比较容易理解的方式是:若某个CPU将缓存回写至内存,其他CPU能够立刻获取这种修改。

内存模型JMM

java内存模型是根据缓存一致性协议,解决缓存与内存读写同步问题的过程抽象。

JMM定义了程序中变量的访问规则。这些规则保证了:变量在工作内存与主内存间安全的传递

与JVM堆栈的对应关系

主内存与工作内存与JVM的堆栈不是一个划分方式,如果一定要勉强对应,如下:

  • 主内存:堆,为JAVA虚拟机分配的物理内存

  • 工作内存:栈(线程使用到的变量的主内存副本),对应寄存器、高速缓存。
    JAVA并发(四)JMM、CAS、原子性、可见性、有序性、synchronized对象锁、JVM锁优化_第2张图片
    虚拟机规定:

  • 线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。

  • 不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

工作内存与主内存交互操作

主内存与工作内存间变量的操作基于8种最小粒度的原子操作分类:

①lock:作用于主内存变量,把变量标识为某一个线程独占的状态,其他线程无法访问

②unlock:作用于主内存变量,释放被lock的变量

③read:作用于主内存变量,把变量从主内存传输到工作内存

④load: 作用于工作内存变量,把read传输过来的变量加载至工作内存的变量副本中。

⑤use:作用于工作内存变量,cpu发出使用变量值的字节码指令时,jvm将该变量从工作内存传递给cpu

⑥assign:作用于工作内存变量,cpu发出赋值该变量的字节码指令时,jvm将从cpu获取的新值赋给工作内存的该变量。

⑦store: 作用于工作内存变量,把工作内存中一个变量的值传输到主内存

⑧write: 作用于主内存变量,把store传输过来的变量,加载至主内存变量中

如下图,是其中6种原子操作,JMM规定了read与load、store与write必需成对出现(避免工作内存或主内存不接收变量),所以图中直接将它们画在一起,表示工作内存与主内存之间的交互,

use、assign表示CPU与工作内存之间的交互。在执行assign后,必需执行store&write(给其他线程获取变量的新值);在执行use后,不得执行store&write(变量值未改动,避免浪费资源执行store&write);

lock与unlock比较特殊,用在主内存中控制变量的访问权限。正因为lock、unlock只作用于主内存中,JMM规定了:变量只能在主内存中产生,在执行use或assign前,必需先执行read&load。这样才真正的控制了CPU对变量的使用权限。lock、unlock对应的字节指令为monitorenter、monitorexit,更上层的是synchronized关键字。进入synchronized修饰的代码块,会添加monitorenter指令,退出synchronized修饰的代码块,会添加monitorexit。

关于lock:

  • lock&unlock可以保证原子性、有序性:一个变量同一时刻只允许一个线程(线程A)对其lock,其他线程想使用变量,必须等线程A执行unlock,A线程可以在lock状态下多次lock,多次lock后,只有线程A再执行相同次unlock,其他线程才能使用该变量;
  • lock&unlock可以保证可见性:①一旦线程执行了lock操作获取了变量的访问权限,该线程的CPU使用变量时,必需从主内存read&load变量;②一旦线程需要执行unlock,必需先执行store&write;③因为unlock会伴随着store&write,如果没有获取lock的线程可能会执行unlock,回写变量值至主内存,因此JMM又规定了:没有执行lock的线程不能执行unlock;上述三点保证了获取锁的线程永远能够看到变量的最新值。

JAVA并发(四)JMM、CAS、原子性、可见性、有序性、synchronized对象锁、JVM锁优化_第3张图片
将上述规则简单总结如下:

  1. lock&unlock对应synchronized修饰的代码块:目的是保证线程安全,保证原子性、有序性、可见性
  2. 变量只能在主内存产生:目的是控制变量的访问权限
  3. read和load、store和write操作必须成对出现:目的是保证变量在主内存与工作内存之间的传递
  4. 执行assign后必须执行store&write:目的是给其他线程获取变量的最新值的机会,缓存一致性协议规定了执行了store&write(回写操作)后,其他处理器能嗅探到变量的更新,会将自身工作内存中的变量设置为无效态。值得注意的是,JMM没有规定执行assign后必须立刻执行store&write,所以不能保证其他线程立刻能够获取到变量的修改。
  5. 执行use后不得执行store&write,目的是节省资源

可见性

可见性指的是内存可见性,当一个线程修改了对象状态后,其他线程能够立刻看到发生的状态变化。

在一个处理器里修改的变量值,JMM只规定了执行assign后必须执行store&write,但没有规定立刻执行store&write,所以变量的修改不一定能及时回写缓存,这种变量修改对其他处理器变得“不可见”了。所以,如果要保证可见性,要么保证变量一旦创建就不再变化(final修饰的变量),要么保证执行assign后必须立刻执行store&write。对应以下三种:

  • final修饰符,final修饰的变量在初始化后就不会更改,所以只要在初始化过程中没有把this指针传递出去也能保证对其他线程的可见性。
  • volatile修饰符,执行assign后必须立刻执行store&write,**“立刻”**二字保证可见性。
  • 加锁,在上一章中介绍过,JMM规定了执行unlock前必须执行store&write,因此,lock与unlock也能保证可见性。

可见性与原子性对比如下:

  • 原子性:一个线程使用对象期间,对象不被其他线程修改。
  • 可见性:一个线程A使用对象期间,对象可以被其他线程修改,但是线程A能够看到发生的变化。

加锁的含义不仅仅局限于原子性,还包括内存可见性,但在不要求互斥、只要求内存可见性的情况下,再使用锁就显得有些重了,此时可以使用volatile修饰符保证可见性。

volatile原理

Java代码

private volatile TestInstance instance = new TestInstance();

上述代码的汇编代码:

0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock  addl $0x0,(%esp);

有Violatile修饰的变量在汇编阶段,会多出一条lock前缀指令,它在多核处理器下引发两件事情:

  • 将当前处理器缓存行的数据写回内存
  • 写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效(缓存一致性协议)

回写内存

对Volatile变量进行写操作时,JVM就会向处理器发送一条lock前缀的指令(注意,这个lock前缀指令与JMM的lock完全不同,JMM的lock对应的指令是monitorenter),告诉处理器,执行完assigi操作后,必须立刻执行store&write,将这个变量所在缓存行的数据写回到主内存

缓存一致性协议

即使写回到内存,其他处理器也有可能使用内部的缓存数据,从而导致变量不一致;

缓存一致性协议规定了:当前CPU的缓存写入内存,写入动作也会引起别的CPU无效化其Cache,相当于让新写入的值对别的CPU可见。

所以,volatile回写内存+缓存一致性协议 实现了可见性。

可保证有序性

volatile的核心是lock前缀指令,它负责通知cpu将当前操作立即回写内存,正是因为回写内存的存在,指令重排无法跨过lock信号对应的指令。因此,lock前缀实际上是一种内存屏障,cpu不会跨过该屏障进行重排序,volatie不仅可以保证可见性,也保证有序性。

volatile使用场景

当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对该变量的写入操作不依赖变量的当前值,或者能确保只有一个线程更新变量的值
  • 该变量不与其他变量一起纳入不变性条件中(因为volatile变量不能确保原子性)

举例一些应用场景:标识一些事件的发生,如初始化、销毁、判断是否处于某个状态,状态的变化只有一个线程能够触发。

有序性

cpu通过指令重排提高效率。对于单线程执行的代码,线程内表现为串行的语义(指令重排会保证结果不变),是有序的;对于多线程执行的代码,无法保证变量赋值的操作顺序与代码执行顺序一致。

private boolean initialized = false;

public void init() {
    while(!initialized) {
        dosomething();
        initialized = true;
    }
}

private void dosomething() {
    // ... 省略
}

由于指令重排序优化,导致A线程最后一句initialized=true被提前执行,此时并未执行dosomething(),线程B再使用init中初始化的配置信息时,可能会出错。

解决方案

有两种方法:

  • 使用volatile保证有序性:Lock#会提供一个内存屏障,因为它保证当前操作立即同步回主内存,所以指令重排只能重排该屏障前或屏障后的代码,不可以跨过该屏障进行重排序。
  • 加锁:指令重排的问题在单线程环境下没有影响,所以通过加锁的方式保证有序性。

原子性

原子性指的是一个操作可以作为一个不可分割的操作来执行。也指:对于一个线程正在使用的对象,使用过程中不会被其他线程修改。

public int autoIncrease(int i) {
    return ++i;
}

++i或i++看上去是一个操作,但并非是原子操作,它包含了三个操作:

  • 读取i的值
  • 将值加1
  • 将计算结果写入i

这是一个操作序列:读取 - 修改 - 写入,其结果状态依赖于之前的状态。

两个线程同时执行该操作会导致线程不安全,因为两个线程可能会交替的执行上述三个操作,某一个线程读取到的值可能是无效的、过时的。

为了保证线程安全,需要确保两个线程按照顺序依次执行上述三个操作,即线程1执行完了三个操作后,线程2才能开始执行这三个操作。

在并发编程中,这种由于“不恰当”执行顺序而出现的不正确的结果称为:竞态条件。

“不恰当”的执行顺序是CPU优化导致的,是客观存在、有利于提高处理速度的,这种执行顺序总是存在的。程序员能掌控的是编写线程安全的代码,使其在竞态条件下也能满足线程安全。

竞态条件

最常见的竞态条件是:先检查后执行

下面的例子是一个懒加载单例类,它不是线程安全的:

public class LazyLoadSingleton {
	private LazyLoadSingleton singleton = null;
	
	public LazyLoadSingleton getSingleton() {
		if (singleton == null) {
			singleton = new LazyLoadSingleton();
		}
		
		return singleton;
	}
}

线程A和线程B同时执行getSingleton。A看到singleton为空,因此创建一个新的LazyLoadSingleton实例;线程B同样要判断singleton是否为空,而B判断时singleton是否为空,取决于A执行到哪一步,即不可预测的执行顺序,可能会导致线程不安全。

避免竞态条件—复合操作

复合操作指将前文中的自增、先检查后执行的操作分别组合成原子操作,这样就能保证线程安全。

在java.util.concurrent.automic包中包含了一些原子变量类,用于实现在数值和对象引用的原子状态转换。比如自增的复合操作

public class AutoIncrease {
	private final AtomicLong count = new AtomicLong(0);
	
	public long increase() {
		return count.incrementAndGet();
	}
}

AtomicLong.incrementAndGet底层通过CAS实现了复合操作。

乐观锁CAS

CAS是一种乐观锁(synchronized为悲观锁),CAS对应了硬件指令CMPXCHG,该指令对应着"比较并交换的操作,如果一个值原来是A(预期值),修改为B,在CPU回写至内存时,会检查当前值是否为A(比较),如果为A,则将值更新为B(交换)。

CPU循环进行CAS操作直到成功为止。CAS虽然很高效的实现了原子性,但是CAS仍然存在三大问题:

  • ABA问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作。

ABA问题

CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。 从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。

自旋时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用:

  • 延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源。
  • 避免在退出循环时因内存顺序冲突而引起CPU流水线被清空,从而提高CPU的执行效率。

只能保证一个共享变量原子操作

对多个共享变量操作时,循环CAS就无法保证操作的原子性,有两种办法解决:

  • 用锁(下一节重点介绍)
  • 把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了类AtomicReference、AtomicStampedReference(解决ABA)来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

多个复合操作存在的问题

将上述代码进行如下改动:

// 改动1
public class AutoIncrease {
	private final AtomicLong count1 = new AtomicLong(0);
    private final AtomicLong count2 = new AtomicLong(0);
	
	public long increase() {
		long num1 = count1.incrementAndGet();
        long num2 = num1 + count2.incrementAndGet();
        return num2;
	}
}

// 改动2
public class AutoIncrease {
	private final AtomicLong count = new AtomicLong(0);
	
	public long increase() {
        if (count.incrementAndGet() % 2 == 0) {
            return count.get();
        }
        return count.get() + 1;
	}
}

改动1中,方法increase中包含两个原子操作,但是increase方法的返回值num2,涉及到了多个变量:count1和count2,这两个变量之间不是独立的,而是某个变量的值会对其他变量的值进行约束。这就导致了increase整体成为了一个非原子的方法,不是线程安全的。

总结1:多个变量彼此不是相互独立时,不是原子操作

改动2中,先检查count自增后是否为为偶数,为偶数则直接返回,为奇数则加1再返回,这是典型的先检查后执行操作,increase整体是一个非原子方法,不是线程安全的。

一个操作是否是原子的,要看它包含的所有操作是否是

总结2:对于先检查后执行的操作,不是原子操作

对于改动1和改动2出现的非原子操作,JAVA提供了加锁机制,用来保证在一个原子操作中更新所有相关的状态变量,即将上述操作合并为一个整体,这种合并为一个整体是语言逻辑层面的,通过对象锁实现的,不是系统指令集层面的(CAS),锁的粒度是可以在写代码时掌控的。

对象锁

对象锁可以将多个操作复合为一组同步的操作,保证原子性、可见性、有序性,避免竞态条件。对象锁又称内置锁、Monitor锁、synchronized锁,每一个Java对象自带了一把看不见的锁,通过synchronized关键字使用该锁。它可以保证原子性、有序性、可见性。正是因为如此强大,容易导致滥用。

synchronized的原理

synchronized的实现离不开Monitor。Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),Monitor包含了下列信息:

字段 含义
owner 占有该Monitor的线程的唯一标识,为Null时表示没有线程占用
EntryQ 关联一个系统互斥量(semaphore),阻塞所有试图锁住monitor record失败的线程
RcThis 被阻止的线程的个数
Nest 计数器,用来实现重入锁,没有线程持有monitor时该值为0
HashCode 与monitor关联的对象的hashcode
Candidate 只有两个值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁

以下面的代码为例:

public void synMethod() {
    synchronized(this) {
        // do smothing
    }
}

上述代码反编译后:

monitorenter
...
monitorexit

synchronized对应了两个指令:monitorenter、monitorexit,这两个指令对应的JMM原子操作是lock、unlock。

以JVM中对monitorenter的解释为例:

monitorenter :

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

翻译一下:每个对象都关联着一个monitor,当monitor被某一个且只能被一个线程占用后,monitor就会处于锁定状态。线程执行到monitorenter后,尝试去获取与monitor关联的对象的所有权,此时,会有两种结果:

  • 如果monitor的Nest值为0,则线程会占有monitor。
  • 如果monitor被占有了,通过owner进行判断是否为当前线程占有的,如果是,那么该线程重入一次,计数器Nest的值加1
  • 如果monitor被其他线程占有了,当前线程阻塞,直到Nest值为0

总结一下:每个对象的对象头中都有一个Mark Word用于存储运行时数据,Mark Word中包含了Lock Word,Lock Word记录了Monitor的指针,Monitor中的owner字段记录了持有该Monitor的线程唯一标识,Nest字段是一个计数器,用来表示该Monitor是被持有了几次,当线程执行到montorenter指令时,会判断计数器,计数器为0时,直接持有该锁,不为0时,进一步判断owner是否为当前线程,为当前线程则将计数器加1,继续持有该锁,不为当前线程则阻塞等待至计数器为0。

synchronized的使用

下面给出synchronized的几种常用应用场景:

  • 普通方法上,锁当前实例对象
  • 静态方法上,锁当前类的class对象
  • 代码块,锁括号里的对象
  • 继承的方法上,锁子类的实例对象,锁两次

这里比较容易令人困惑的是应用在继承方法上:首先,继承的本质是让子类拥有父类对象的引用,super关键字就是告知JVM,子类对象需要通过父类的引用调用父类的方法,因此,调用者是子类对象,锁的也是子类对象。在下面代码示例中,进入子类的paraentMethod()方法时,获取一次子类对象锁,调用super.paraentMethod()时,又一次获取了子类的对象锁,共在子类实例对象上加了两次锁。

public class SynParent {
    public synchronized void paraentMethod() {
        System.out.println("method paraent start...");
    }
}

public class SynchronizedTest extend SynParent{
    private final volatile Object objLock = new Object();
    
    // 用在继承的方法上,锁子类的当前实例对象
    @Override
    public synchronized void paraentMethod() {
        super.paraentMethod();
        System.out.println("method paraent end...");
    }
    
    // synchronized应用在普通方法上,锁当前实例对象
	public synchronized void method1() {
		System.out.println("method 1 start...");
        // do something
        
        System.out.println("method 1 end...");
	} 
    
     // synchronized应用在静态方法上,锁当前类的class对象
    public static synchronized void method2() {
		System.out.println("method 2 start...");
        // do something
        
        System.out.println("method 2 end...");
	}
	
    // synchronized应用在代码块,锁当前实例对象
    public void method3() {
        synchronized(this) {
            System.out.println("method 3 start...");
            // do something
        }
        
        System.out.println("method 3 end...");
    }
    
    // synchronized应用在代码块,锁自定义实例对象
    public void method4() {
        synchronized(objLock) {
            System.out.println("method 4 start...");
            // do something
        }
        
        System.out.println("method 4 end...");
    }
}

JVM的锁优化

以64位JVM为例,它的对象头中的Mark Word如下,分别对应了对象的四种状态,无锁、偏向锁、轻量级锁、重量级锁,此外,虚拟机还有自旋锁、锁消除、自旋自适应锁等机制,本节将会逐个介绍。

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

JAVA并发(四)JMM、CAS、原子性、可见性、有序性、synchronized对象锁、JVM锁优化_第4张图片
重量级锁就是前面我们详细分析过的synchronized锁,线程需要持有与对象相关联的monitor,montior中包含了线程唯一表示、系统互斥量、计数器等信息。系统互斥量导致该锁是重量级。重量级锁不属于锁优化,所以不再单独列为一节。

轻量级锁

synchronized原理中已提到过,线程栈帧中有一个名为Lock Record(锁记录,又叫Lock Word)的空间,用于存储对象的Mark Word的拷贝。

线程尝试获取轻量级锁时,虚拟机使用CAS将对象的Mark Word更新为指向Lock Record的指针,如果此次更新成功,那么这个线程就拥有了该对象的锁。锁标志位更新为00,之所以称之为轻量级,是去除了同步使用的互斥量

如果CAS操作失败,虚拟机首先检查对象的Mrak Word是否指向当前线程,如果是,那就可以直接进入同步块执行,如果对象的Mark Word没有指向当前线程,说明锁已经被其他线程抢占了,轻量级锁不再有效,膨胀为重量级锁,锁标志位变为10。

偏向锁

如果说轻量级锁是在无竞争的状态下使用CAS操作去除同步使用的互斥量,那偏向锁就是在无竞争的状态下把整个同步都消除掉,连CAS操作也省去。

对象头使用54bit存储偏好的线程ID,再使用2bit存储epoch(偏向锁获取的时间戳),当锁对象第一次被线程获取时,进入偏向模式,同时会进行一次CAS(只进行一次),把获取到该锁的线程ID记录在Mark Word中,该线程以后再进入与锁相关的同步块时,虚拟机不再执行任何同步操作,直至另外一个线程尝试获取该锁。偏向锁可以提高带有同步(如synchronized关键字)但实际运行中无线程竞争的代码的效率,即只有一个线程获取该锁,那么使用偏向锁模式。

偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。-XX:+UseBiasedLocking开启偏向锁。

轻量级锁与偏向锁的区别:

  • 偏向锁只执行一次CAS,后续同一个线程获取锁时完全没有同步操作,偏向锁每次都要执行CAS
  • 偏向锁在有其他线程尝试获取锁时就失效,轻量级锁在其他线程获取锁成功后才会失效

自旋锁

如果有两个以上的处理器,处理器A的线程获取了锁,线程B请求获取同一个对象的锁时会阻塞,在大多数情况下,线程A占有锁的时间不会太久,为了这段很短的时间去挂起和恢复线程B并不值得。

因此,JVM让后面请求锁的那个线程B执行一个忙循环(自旋),不放弃处理器的执行时间,看看处理器A的线程是否很快是否锁。这种情况适用于处理器A的线程只需要很短的时间就释放锁,省去了B线程挂起去等待A释放锁和B线程恢复的时间。自旋锁默认开启,默认次数10次,使用-XX:PreBlockSpin设置次数 。

自旋自适应锁

如果对于某个锁,自旋很少成功过,以后获取该锁可能省去自旋过程。如果对于某个锁,经常很短时间就成功,虚拟机认为这次自旋很有可能再次成功,会允许自旋等待更长的时间。有效的解决了自旋等待时间过长时白白耗费CPU资源的问题。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

比如编写了一段看起来没有同步的代码,但是经javac编译后,发现包含了三个sb.append()操作,每个sb.appen()方法都包含一个同步块,锁就是sb对象,虚拟机观察sb,发现它的动态作用于被限制在concatString()方法内部,也就是说,其他线程访问不到当前线程的sb对象,因此,这里虽然有锁,但是可以消除。JVM就会消除该锁。

// 一段看起来没有同步的代码
public String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}
// javac 转化后,可以看出包含了三个sb.append()操作,这些操作都是同步的,耗费性能
public String concatString(String s1, String s2, String s3) {
    StringBuilder sb = new StringBuilder();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    
    return sb.toString();
}

锁粗化

原则上,在编写代码时,锁的粒度越小越好,但是如果一系列连续操作都对同一个对象反复加锁和解锁,甚至加锁出现在循环体中,即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。

如下面的例子中,第一个while循环对当前实例对象加锁1次,第二个while循环对当前实例对象加锁99次,虚拟机优化后,只加锁了1次。

public class TooMuchLock {
    private int sum;
	private int i;
	private int j;

	public void setSum() {
    	synchronized(this) {
        	while(i< 100) {
            	i++;
        	}
    	}
    	System.out.println("suming...");
    
    	while(j < 100) {
        	synchronized(this) {
            	j++;
        	}
    	}
    
    	sum = i + j;
	}
}


// 虚拟机锁粗化后
public void setSum() {
    synchronized(this) {
        while(i < 100) {
            i++;
        }
         System.out.println("suming...");
        while(j < 100) {
            j++;
        }
        sum = i + j;
    }
}

性能提升原则

开发过程中,尽量遵循以下原则:

  • 尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,比如尽量不要对I/O操作加锁。
  • 不要频繁的对同一个对象加锁,即使虚拟机有锁粗化机制
  • 不要盲目的为了提高性能而细化锁的粒度,细化锁的粒度时,要时刻警惕线程安全性。

小结

本篇主要讨论了以下内容:

  • 通过CAS、对象锁保证原子性;
  • 通过volatile、对象锁保证可见性、有序性。
  • CAS存在的三个问题
  • 缓存一致性协议的定义
  • JVM的锁优化方法。

下一篇主要介绍对象的安全发布。

你可能感兴趣的:(并发)