# 七、高效并发

# 1. Java 内存模型

  • 所有共享变量都存放在主内存中,每个线程都有自己的工作内存,工作内存中保存该线程使用到的变量的主内存副本拷贝。
  • 线程对变量的所有操作(读、写)都应该在工作内存中完成。
  • 不同线程不能相互访问工作内存,交互数据要通过主内存。
  • Java 内存模型规定了一些操作来实现内存间交互,JVM 会保证它们是原子的。
    • lock:锁定,把变量标识为线程独占,作用了主内存变量
    • unlock:解锁,把锁定的变量释放,别的线程才能使用,作用于主内存变量
    • read:读取,把变量值从主内存读取到工作内存中
    • load:载入,把 read 读取到的值放入工作内存的变量副本中
    • use:使用,把工作内存中一个变量的值传递给执行引擎
    • assign:赋值,把从执行引擎接收到的值赋给工作内存里面的变量
    • store:存储,把工作内存中一个变量的值传递到主内存中
    • write:写入,把 store 进来的数据存放到主内存的变量中
image-20210311151609196
  • 有 lock 必有 unlock
  • 有read 必有 load
  • 有 store 必有 write
  • 上述操作 JVM 会让其按顺序执行,但是不保证连续执行。
  • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回到主内存中。
  • 不允许一个线程无原因地(没有发生功能任何 assign 操作)把数据从线程的工作内存存到主内存中。
  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,也就是对一个变量实施 user 和 store 操作之前,必须先执行了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其执行 lock 操作,但 lock 操作可以被同一个线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,将会情况工作内存中此变量的值,在执行引擎使用这个变量前,需要重新指向 load 或 assign 操作初始化变量的值。
  • 如果一个变量没有被 lock 操作,则不允许对它执行 unlock 操作,也不能 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存(执行 store 和 write 操作)。

# 2. 可见性

可见性:一个线程修改了变量,其他线程可以知道。

# 2.1 volatile

volatile 基本山是 JVM 提供的最轻量级的同步机制,用 volatile 修饰的变量,对所有线程可见,即对 volatile 变量所做的写操作能立即反映到其他线程中。

用 volatile 修饰的变量,在多线程环境下仍然是不安全的。

用 volatile 修饰的变量,是禁止指令重排优化的。

适合使用 volatile 的情景:

  • 运算结果不依赖变量的当前值
  • 能确保只有一个线程修改变量的值

# 2.2 synchronized

# 2.3 final

# 3. 指令重排

指的是 JVM 为了优化,在条件允许的情况下,对指令进行一定的重新排序,直接运行当前能够立即执行的后续指令,避开获取下一条指令所需数据造成的等待。

JVM 只考虑线程内串行语义,不考虑多线程间的语义。

不是所有的指令都能重排,比如:

  • 写后读:a = 1; b = a;
  • 写后写:a = 1; a = 2;

# 3.1 基本规则

  • 程序顺序原则:一个线程内包装语义的穿行性。
  • volatile 规则:volatile 变量的写,先发生于读。
  • 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。
  • 传递性:A 先于 B,B 先于 C,那么 A 必然先于 C。(指令)
  • 线程的 start() 方法先于它的每一个动作。
  • 线程的所有操作先于线程的总结。
  • 线程的中断(interrupt())先于被中断线程的代码。
  • 对象的构造函数执行结束先于 finalize() 方法。

# 4. 线程安全处理

# 4.1 final

  • 不可变的东西是线程安全的。

# 4.2 互斥同步(阻塞同步)

synchronized、ReentrantLock。

目前这两个方法性能已经差不多了,建议优先选用 synchronized。ReentrantLock 增加了如下特性:

  • 等待可中断:当持有锁的线程长时间不释放锁,正在等待的线程可以选择放弃等待。
  • 公平锁:多个线程等待同一个锁时,须严格按照申请锁的时间顺序来获得锁。
  • 锁绑定多个条件:一个 ReentrantLock 对象可以绑定多个 condition 对象,而 synchronized 是针对一个条件的,如果要多个,就得多个锁。

ReentrantLock 加了诸多特性,也带来了诸多额外的开销和开发的难度。

# 4.3 非阻塞同步

是一种基于冲突检测的乐观锁定策略,通常是先操作,如果没有冲突,操作的成功了,有冲突再采取其他方式进行补偿处理。

# 4.4 无同步方案

尽量不要有临界资源的存在。

# 5. 锁优化

# 5.1 自旋锁

自旋:如果线程可以很快获取锁,那么可以不在 OS 层挂起线程,而是让线程做几个忙循环,这就是自旋。

自适应自旋:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者状态来决定。

  • 如果锁被占用的时间很短,自旋成功,那么能节省线程挂起以及切换时间,从而提升系统性能。
  • 如果锁被占用时间很长,自旋失败,会白白耗费处理器资源,降低系统性能。

# 5.2 锁消除

在编译代码的时候,JVM 检查到根据不存在共享数据竞争,自然也就无需同步加锁了,JVM 会帮我们把我们加的锁去掉。

-XX:+EliminateLocks 来开启。

同时要使用 -XX:+DoEscapeAnalysis 开启逃逸分析,所谓逃逸分析:

  • 如果一个方法中定义的一个对象,可能被外部方法引用,称为方法逃逸。
  • 如果对象可能被其他外部线程访问,称为线程逃逸。
    • 比如赋值给类变量或者可以在其他线程中访问的实例变量。

(因为一旦出现逃逸,就不能轻易消除锁了,无法得知外部是如何使用我们的资源了,该同步还是得同步。)

# 5.3 锁粗化

通常我们都要求同步块要小,但一系列连续的操作导致对一个对象反复的加锁和解锁,这会导致不必要的性能损耗,这种情况建议把锁同步的范围加大到整个操作序列。

# 5.4 轻量级锁

轻量级是相对于传统锁机制而言的,本意是没有多线程竞争的情况下,减少传统锁机制使用 OS 实现互斥所产生的性能损耗。

实现原理很简单,就是类似乐观锁的方式。

如果轻量级锁失败,表示存在竞争,那么 JVM 就会将其升级为重量级锁,导致性能下降(因为多了一次尝试)。

# 5.5 偏向锁

偏向锁是在无竞争情况下,直接把整个同步消除了,连乐观锁都不用,从而提高性能。

所谓偏向,就是偏向,即锁会偏向于当前已经占有锁的线程。

只要没有竞争,获得偏向锁的线程在将来进入同步块也不需要做同步。

当有其他线程请求相同的锁时,偏向模式结束。

如果程序中大多数锁总是被多个线程访问的时候,也就是竞争比较激烈,偏向锁反而会降低性能。

使用 -XX:-UseBiasedLocking 来禁用偏向锁,默认是开启的。

# 5.6 JVM 中获取锁的步骤

st=>start: 尝试偏向锁
e=>end: 尝试普通锁,使用 OS 互斥量在操作系统层挂起
op2=>operation: 尝试轻量级锁
op3=>operation: 尝试自旋锁
st->op2->op3->e

# 5.7 同步代码的基本规则

  • 尽量减少锁持有时间
  • 尽量减小锁的粒度
上次更新: 8/27/2021, 6:21:27 PM