Java内存模型

由于不同的物理硬件和操作系统使用了不同的内存模型, 因此Java虚拟机需要提供一套内存的标准, 使得Java在不同平台下有同样的内存模型.

Java内存模型主要目标是定义程序中各个变量的访问规则. Java内存模型规定所有的变量都存储在主内存中, 同时每个线程还有自己的工作内存, 工作内存保存了线程需要使用的变量的主内存的副本. 线程对变量的所有操作都在工作内存上进行, 而不能直接对主内存读写. 各个线程的工作内存相互独立, 只能通过主内存交换信息.

内存间操作

Java内存模型定义了以下的8种操作来完成工作内存与主内存的同步, 每一种操作都是原子的.

操作 作用位置 作用
lock 主内存 将变量标记为线程独占状态
unlock 主内存 对变量解除线程独占状态
read 主内存 将变量的值从主内存取出
load 工作内存 接受主内存取出的值并放入工作内存
use 工作内存 将工作内存的值传递给执行引擎
assign 工作内存 将从执行引擎受到的值写入工作内存
store 工作内存 将变量的值从工作内存取出
write 主内存 接受工作内存取出的值并写入主内存

read和load, store和write不能单独使用, 但可以在两者之间插入其他无关的指令, 例如对主内存的a,b进行访问可以执行

1
2
3
4
read a
read b
load b
load a

这些指令还需要以下的规则

  • 不允许丢弃assign操作, 即执行引擎的计算结果必须同步到主内存
  • 不允许无原因的(无assign操作)把数据从工作内存同步到主内存
  • 变量只能在主内存创建,
  • 一个变量只能被一个线程lock, 但一个线程可以多次lock同一变量, 最后需要unlock同样的次数
  • 一个变量被lock以后,会清除工作内存的值, 之后需要重新load或assign
  • 不可以unlock没有被lock的变量, 也不可以unlock其他线程lock的变量
  • 变量执行unlock之前, 必须同步回主内存

volatile变量的特殊规则

volatile是Java提供的最轻量级的同步机制. volatile有两个特性, 第一是保证此变量对所有线程的可见性, 即一个线程修改了这个变量的值, 其他线程可以立即得知. 对于普通的变量, 一个线程修改了变量以后, 需要先写回主内存, 其他线程从主内存读取后才会获得变量新的值.

volatile可以保证每次读取的值都是最新的, 但是不能保证并发安全. 只有以下两种情况适合使用volatile保证原子性

  • 运算结果不依赖变量的当前值, 或者只有一个线程修改变量的值
  • 变量不需要与其他的状态变量共同参加不变约束

第二个特性是禁止指令重排序优化. 普通的变量只保证在依赖赋值结果的地方获得正确的结果, 而不保证计算顺序与代码顺序一致.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Map configOptions;
char[] configText;
volatile boolean initialized = false;

// 假设在A线程中执行配置文件初始化的操作
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized = true;

// 假设B线程通过initialized变量判断配置文件是否处理完毕
while(!initialized){
sleep();
}
doSomethingWithConfig();

由于指令重排序, 当initialized为普通变量时, initialized = true;语句可能会提前执行, 这样就会导致B线程出现错误.

Java线程的实现

线程一般有三种实现方法, 分别是使用内核线程实现, 使用用户线程实现, 使用用户线程加轻量级进程混合实现.

内核线程实现是将Java的线程映射到操作系统内核直接支持的进程上(Kernel-Level Thread, KLT), 这种线程由内核完成线程的调度功能. 但程序一般不直接使用KLT, 而使用KLT的一种高级接口轻量级进程(Light Weight Process, LWP)

由于使用了内核支持的线程, 因此一条线程阻塞不会影响其他线程. 但线程调度由操作系统完成, 需要进行用户态和内核态的切换. 切换代价比较高. 内核线程需要消耗系统的资源, 因此操作系统能支持的内核线程数量也有限.

用户线程实现是在用户态实现线程的调度功能, 从而操作系统对用户线程不可感知. 由于不借助于操作系统内核, 因此不需要切换用户态, 线程调度的消耗更低. 但诸如处理器分配和线程映射到特定处理器之类的功能也因为不借助于操作系统内核而难以实现或根本无法实现.

混合实现混合了上面两种线程的实现方法.

Java虚拟机早期有基于用户线程的实现方案, 目前主流的Java虚拟机都基于内核线程实现

Java API与线程安全

Java API标记为线程安全的类并不能保证绝对的线程安全. 例如Vector类在所有的操作上都加上了synchronized关键字, 但是存在多个线程同时修改和删除时, 依然会产生线程问题.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static Vector<Integer> vector = new Vector<>();

public static void main(String[] args) {
while (true){
for(int i=0;i<10;i++){
vector.add(i);
}

Thread removeThread = new Thread(() -> {
for(int i=0;i<vector.size();i++){
vector.remove(i);
}
});

Thread printThread = new Thread(() -> {
for(int i=0;i<vector.size();i++){
System.out.println(Thread.currentThread().getName()+ " "+(vector.get(i)));
}
});

removeThread.start();
printThread.start();

// 这里使用空循环控制线程的数量
while (Thread.activeCount() > 20);
}
}

执行上述代码, 可以在控制台发现抛出了如下的异常(注意: 由于是子线程的异常, 此异常只会在控制台显示而不会终止当前程序)

1
2
3
4
java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 10
at java.util.Vector.remove(Vector.java:834)
at top.lizec.TestVector$1.run(TestVector.java:18)
at java.lang.Thread.run(Thread.java:748)

Vector虽然所有的方法都使用了synchronized关键字进行锁定, 但是两个方法连续执行时, 无法保证不发生线程切换, 因此执行删除任务时, 获得的size数据可能已经失效, 进而导致越界.

对于这种场景, 还是需要使用同步的方式来手动锁定一段代码.

实现线程安全

互斥同步

互斥同步的基本操作就是synchronized关键字, 此外Java也提供了ReentrantLock. 两者的实现并没有太大区别, 但ReentrantLock具有如下的一些特点

  1. 等待可中断
  2. 公平锁
  3. 绑定多个条件

使用ReentrantLock时, 等待线程可以决定是都中断等待. 创建ReentrantLock时, 可以确定是否使用公平锁, 是否绑定多个Condition.

在JDK1.6以前, ReentrantLock相比synchronized关键字具有更好的性能, 但在JDK1.6以后, 两者在性能上并没有显著的区别. 考虑到synchronized由编译器自动控制锁释放, 因此应该默认优先使用synchronized关键字.

非阻塞同步

在这种模式下, 程序假设并不存在频繁的竞争, 在大部分情况下, 不进行同步操作, 而是直接操作数据. 如果发现数据产生冲突, 再进行补救的方法.

比较和交换(Compare-and-Swap, CAS) 一条CPU指令, 大部分指令集中都有相同或类似的指令. CAS操作需要三个操作数, 即变量内存地址V, 旧的预期值A, 新的值B. CAS执行首先判断变量V的值是否为旧的预期值A, 如果满足则使用新的值B替换, 否则不执行任何操作. 如果是否成功执行, 都会返回V的旧值.

无同步方案

如果两个线程之间没有数据共享, 那么就不需要进入任何的同步处理, 从而天然的实现线程安全. 这样的代码有两类常见的方案


可重入代码(Reentrant Code), 也称为纯代码(Pure Code), 这样的代码在任意位置中断执行, 转去执行其他代码, 再恢复执行也不会导致错误. 可重入代码都是线程安全的.

可重入代码一般都具有如下的一些特点

  • 不依赖堆上变量, 不依赖公共变量
  • 状态量由参数传入
  • 不调用非可重入方法

如果一个方法的结果可以预测, 只要输入了相同的数据, 则必定返回相同的输出, 则这个方法是可重入的.


**线程本地存储(Thread Local Storage)**的核心思想是将使用相同数据的操作尽可能集合到一个方法之中, 从而将需要共享的变量变为本地变量.

经典的Web交互模型中, “一个请求对应一个线程”的处理方法就是这种思想的典型表现.

如果一个变量仅仅需要保存在线程本地, 可以使用ThreadLocal对象.

锁优化

自旋锁

由于将线程挂起涉及到系统调用, 整体开销比加大, 所以引入了自旋锁机制. 当一个线程等待一个锁的时候, 并不是立即被挂起, 而是执行一个忙循环, 这个忙循环被称为自旋.

当自旋时间比较短时(例如自旋10次), 自旋锁的效果比较好. 从JDK1.6开始, 引入了自适应自旋锁, 虚拟机会根据之前的情况决定自旋的最大次数.

锁消除和锁粗化

JVM的即时编译器在运行时, 可以分析代码是否需要进行锁定, 如果判断锁定没有必要, 则可以消除相应的锁定. 判断的依据是逃逸分析.

通常情况下, 锁定的范围应该是越小越好, 但如果在循环中使用锁定块, 则会导致频繁的加锁和解锁, 反而导致性能下降. JVM可以探测这种操作, 并且自动将锁的范围扩大到外部, 从而一次加锁即可完成全部操作.

偏向锁与轻量级锁

JVM对于加锁这一操作, 实现了三个不同等级的加锁, 分别是偏向锁, 轻量级锁和重量级锁. 这三个锁分别解决只有一个线程进入临界区, 多个线程交替的进入临界区, 以及多个线程希望同时进入临界区的情况. 在对象的头部, 依据不同的状态, 使用不同的结构存储了不同的信息, 具体如下图所示

image

这一部分数据称为Mark Word, 是实现偏向锁和轻量级锁的关键.

偏向锁 的目的是消除无竞争状态下的整个同步操作. 偏向锁会偏向于第一个获得此锁的线程, 如果后续此锁没有被其他线程获得, 则获得此线程的锁永远不需要进行同步.

当锁对象初次被线程获得时, JVM使用CAS操作替换Mark Word并使用偏向模式, Mark Word中记录了获得此对象锁的线程ID. 一个线程A获得偏向锁后不会主动释放锁, 因此后续A进入此锁的同步块时, 不需要进行任何同步操作(只需要对比线程ID是否相同).

当另外一个线程B尝试获取此锁时, 由于对象上的标记为偏向状态, 因此首先检查A是否还持有这个锁. 如果A已经结束或者不持有这个锁, 那么对象头恢复到未锁定状态, 然后B按照偏向锁的规则重新尝试获得偏向锁. 如果A没有结束, 那么偏向状态结束, 当程序到达安全点时, 暂停A线程, 将其锁定方式替换为轻量级锁, 从而线程A以轻量级锁的方式持有对象. 之后A和B按照轻量级锁的方式竞争.

偏向锁在无竞争状态下获得高性能, 在频繁竞争环境下, 第一次的偏向操作就纯粹是多余操作了. 可以使用-XX:-UseBiasedLocking来控制是否启用偏向锁.


轻量级锁针对多个线程都需要锁, 但基本不存在同时请求锁的情况. 轻量级锁使用CAS实现同步, 因此相比于引入操作系统的信号量, 轻量级锁消耗的性能更少.

在执行轻量锁时, JVM首先在栈上分配一段称为Lock Record空间, 其中存放了当前对象的Mark Word的拷贝, 接下来JVM尝试使用CAS操作将对象头部替换为指向Lock Record的指针. 如果操作成功, 那么获得锁. 后续通过反向使用CAS将记录替换回来即可释放锁.

当线程A和线程B同时尝试获取轻量级锁时, 必然有一个线程的CAS操作失败, 此时可以通过自旋的方式等待锁. 如果一个线程A已经获得锁, 使对象进入锁定状态, 那么另外一个线程B也可以通过自旋等待锁的释放, 如果达到自旋次数后仍未获得锁或者第三个线程尝试获得锁, 则轻量级锁碰撞为重量级锁, 此时Mark Word存储指向重量级锁(互斥量)的指针, 后面等待的线程进入阻塞状态.

与偏向锁一样, 如果线程竞争压力很大, 那么轻量级锁也是多余操作了.


参考资料

最后更新: 2024年04月23日 00:19

版权声明:本文为原创文章,转载请注明出处

原始链接: https://lizec.top/2020/10/29/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3JVM%E4%B9%8B%E5%86%85%E5%AD%98%E4%B8%8E%E7%BA%BF%E7%A8%8B/