Java 高效并发

# Java 高效并发

为了便于移植,Java 多线程内存模型不与硬件关联,不同硬件平台可以使用不同的实现手段

和 CPU 内存与高速缓存做对比 Java 内存模型被分为两大部分:主内存(对应 PC 内存)和工作内存(对应 CPU 高速缓存)

主内存与工作内存之间数据的交互 Java 定义了以下 8 种原子操作(最新的 Java 标准已经采用了新的内存访问协议,但下面 8 中操作也应该了解一下)。从主存读写、工作内存读写、工作引擎三个实体和 lock/unlock 这几个角度很容易记忆下面 8 个操作

  1. lock,标识主内存变量为线程独占
    • 同一个变量可以被一条线程多次 lock,但也需要同样次数的 unlock 才能解锁
    • lock 一个变量时会清空工作内存中此变量的值,在使用这个变量前需要执行 load 和 assign 初始化变量
  2. unlock,释放主内存被锁变量
    • 一个变量实现没有被 lock,那也不允许执行 unlock
    • 对一个变量执行 unlock 之前,必须先使用 store 和 write 把变量同步到主内存中
  3. read,从主内存中读取变量到工作内存中,以便后续的 load 操作
  4. load,作用于工作内存,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中
  5. use,把工作内存中的变量传递给执行引擎
    • 只有对一个变量前一个操作是 load 时,当前线程才可以使用 use;只有对一个变量后一个操作是 use 时才可以对变量执行 load 操作
  6. assign,把一个从执行引擎接收到的值赋予工作内存中的变量
    • 变量在工作内存中改变之后必须把变化同步回主内存
  7. store,把工作内存中的变量传递到主存中
    • 只有对变量执行 assign 操作后才能执行 store;之后后一个操作是 store 才可以执行 assign 操作
  8. write,作用于主内存,把 store 操作从工资内存中得到的变量值放入主内存中

结合上面 8 中操作如果把一个变量从主内存复制到工作内存,那就要顺序执行 read 和 load 操作,Java 内存模型只要求上述两个操作必须按顺序执行(且不能单独出现 read 或 load,同理 write 和 store),没有要求连续执行,即这两个操作之间可以插入其他指令

graph LR A[Java 并发] B[volatile 2特点] C[锁] D[悲观锁] E[乐观锁] F[CAS: incrementAndGet] G[ABA] H[轻量级锁<br/>偏向锁] I[自旋锁 自适应] A --> B A --> C C --> D C --> E E --> F E --> G C --> H D --> I

volatile

volatile 变量和 C/C++ 中的概念是一致的,有以下两个特点

  1. 对所有线程可见,使用 volatile 变量时所有内存会从主存中刷新这个变量,因为线程对 volatile 变量的使用不是互斥的所以 volatile 变量无法保证线程安全,若要保持 volatile 变量的原子性,需要使用同步手段
  2. 禁止指令重排优化

Java 内存模型要求 lock 等 8 个变量操作都具有原子性,但对 64 位数据类型却定义了比较宽松的规定:允许虚拟机将没有被 volatile 修饰的数据读写操作分为两次 32 位的操作来运行。这就导致了多线程非同步情况下读到半个变量的可能性 ,不过大部分商用虚拟机实现都将 64 位数据的操作也实现为原子操作

Java 与线程

JDK 1.2 前 Java 的线程使用协程实现,之后使用系统原生线程

线程安全与锁优化

互斥是方法,同步是目的。Java 中最基本的互斥同步手段是关键字 synchronized

悲观锁

常见的互斥锁是悲观锁,认为只要使用变量就要上锁,无论变量是否出现了竞争条件。随着 CPU 指令的发展我们可以使用基于冲突检测的并发策略,也就是乐观锁。通俗的讲,先进行操作,如果没有发现竞争就认为操作成功,否则就采取其他补偿措施,比如不断的重试,直到成功为止

自旋锁

线程的挂起与恢复是非常耗时的,如果上锁时间很短,使用自旋锁是非常好的优化手段

自适应自旋锁,自旋的次数按一定的策略动态变化

乐观锁

乐观锁与悲观锁最大的区别是前者会先尝试去修改变量,失败后进行补偿,乐观锁避免了线程的阻塞

CAS

乐观锁示例:CAS(Compare And Swap),下面这段代码是 Java 使用 CAS 实现的变量自增,可以用来说明 CAS 的使用方法

public final int incrementAndGet()
{
	for(;;)
	{
		int current = get();
		int next = current+1;
		if(compareAndSet(current, next)) // 非互斥,设置失败则不断尝试
		{
			return next;
		}
	}
}
CAS 的 ABA 漏洞

两个线程分别使用 CAS 实现变量 val 的修改,假设 val 初始值为 val0,线程 A 将 val 修改为 val1 后又修改为 val0,如果在这个过程中线程 B 先读到的是 val0,在修改时 A 已经完成了 val 从 val1 到 val0 的修改过程,val 的状态其实已经发生了变化,但 B 却没有感知到,这个漏洞被称为 ABA。大部分情况下 ABA 对程序的正常运行没有影响

Java 轻量级锁

Java 中的轻量级锁是和系统提供的锁相对应的,本意是在没有多线程竞争的前提下减少传统重量级锁的使用,以减少互斥带来的损耗

轻量级锁所依据的前提是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的,所以没必要一定要使用重量级锁

轻量级锁会先尝试使用 CAS 给对象打标记,如果成功就不用调用重量级锁并标记对象已被其他线程占用;而其他线程在使用对象时也会确认对象是否已被占用

在存在竞争的情况下,轻量级锁会比重量级锁耗时

以 HotSpot 实现为例,HotSpot 对象头中包含 “Mark Word” 结构,Mark Word 一般是 32bits 或者 64bits,其中有两个比特用于标识对象锁标志位

在进入同步代码块(synchronized)时如果对象没有被锁定(01),则 JVM 首先在当前线程栈帧中创建一个名为锁记录(Lock Record)的空间,用于保存锁对象当前的 Mark Word。随后 JVM 尝试使用 CAS 将对象的 Mark Word 更新为指向 Lock Record 的指针,随后当前线程就拥有了此对象的锁。在没有竞争的情况下使用轻量级锁会减少对重量级锁的使用以提高性能,但存在竞争时依旧需要使用重量级锁。因为锁标志位的检查,存在轻量级锁的 JVM 会比纯重量级锁系统更耗时(差别非常小)

Java 偏向锁

偏向锁是轻量级锁的进一步优化,如果 Mark Word 中锁标志为偏向(01),则当前线程在同步块中使用对象时不需要再进行任何的同步操作。偏向锁和轻量级锁一样,可以提高同步但无竞争程序的性能,但存在竞争时偏向锁存在的意义就不大,此时使用参数-XX:-UseBiasedLocking 来禁止偏向锁反而可以提高性能

posted @ 2019-11-09 11:38  jiahu  阅读(227)  评论(0编辑  收藏  举报