一、原子性
1.1 什么是原子性
JMM(Java Memory Model)。不同的硬件和不同的操作系统在内存上的操作有一定差异的。Java 为了解决相同代码在不同操作系统上出现的各种问题,用 JMM 屏蔽掉各种硬件和操作系统带来的差异,让Java 的并发编程可以做到跨平台。
JMM 规定所有变量都会存储在主内存中,在操作的时候,需要从主内存中复制一份到线程内存(CPU内存),在线程内部做计算。然后再写回主内存中(不一定!)。
原子性的定义:原子性指一个操作是不可分割的,不可中断的,一个线程在执行时,另一个线程不会影响到他。
并发编程的原子性用代码阐述:
public class AtomicDemo {
private static int count;
public static void increment() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
当前程序:多线程操作共享数据时,预期的结果,与最终的结果不符。
原子性:多线程操作临界资源,预期的结果与最终结果一致。
通过对这个程序的分析,可以查看出,++ 的操作,一共分为了三步:首先是线程从主内存拿到数据保存到 CPU 的寄存器中,然后在寄存器中进行 +1 操作,最终将结果写回到主内存当中。
1.2 保证并发编程的原子性
1.2.1 synchronized
因为 ++ 操作可以从指令中查看到:
0: getstatic // 从主内存获取数据到寄存器
3: iconst_1
4: iadd // 在 CPU 内部执行 +1 操作
5: putstatic // 将 CPU 寄存器数据写回到主内存
8: return
可以在方法上追加 synchronized 关键字或者采用同步代码块的形式来保证原子性。
synchronized 可以让避免多线程同时操作临街资源,同一时间点,只会有一个线程正在操作临界资源。
4: monitorenter // 需要先获取到锁资源,才可以执行后面的指令。
5: getstatic
8: iconst_1
9: iadd
10: putstatic
13: aload_0
14: monitorexit // 执行执行完之后会释放锁资源,其他线程就可以在第 4 步竞争锁资源了。
1.2.2 CAS
compare and swap 也就是比较和交换,这是一条 CPU 的并发原语。
在替换内存的某个位置的值时,首先查看内存中的值与预期值是否一致,如果一致,执行替换操作。这个操作是一个原子性操作。
Java 中基于 Unsafe 的类提供了对 CAS 的操作的方法,JVM 会帮助我们将方法实现 CAS 汇编指令。
但是要清楚 CAS 只是比较和交换,在获取原值的这个操作上,需要你自己实现。
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
count.incrementAndGet();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
count.incrementAndGet();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
CAS 的缺点:CAS 只能保证对一个变量的操作是原子性的,无法实现对多行代码实现原子性。
CAS的问题:
- ABA问题:问题如下,可以引入版本号的方式,来解决 ABA 的问题。Java 中提供了一个类在 CAS 时,针对各个版本追加版本号的操作:
AtomicStampReference。
AtomicStampedReference 在 CAS 时,不但会判断原值,还会比较版本信息。
public static void main(String[] args) {
AtomicStampedReference<String> reference = new AtomicStampedReference<>("AAA", 1);
String oldValue = reference.getReference();
int oldVersion = reference.getStamp();
boolean b = reference.compareAndSet(oldValue, "B", oldVersion, oldVersion + 1);
System.out.println("修改1版本的:" + b);
boolean c = reference.compareAndSet("B", "C", 1, 1 + 1);
System.out.println("修改2版本的:" + c);
}
- 自旋时间过长问题:
可以指定 CAS 一共循环多少次,如果超过这个次数,直接失败/或者挂起线程。(自旋锁、自适应自旋锁)
可以在 CAS 一次失败后,将这个操作暂存起来,后面需要获取结果时,将暂存的操作全部执行,再返回最后的结果。
1.2.3 Lock
Lock 锁是在 JDK1.5 由 Doug Lea 研发的,他的性能相比 synchronized 在 JDK1.5 的时期,性能好了很多。但是在 JDK1.6 对 synchronized 优化之后,性能相差不大,但是如果涉及并发比较多时,推荐
ReentrantLock 锁,性能会更好。
示例代码:
private static int count;
private static final ReentrantLock lock = new ReentrantLock();
public static void increment() {
lock.lock();
try {
count++;
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
ReentrantLock 可以直接对比 synchronized,在功能上来说,都是锁。但是 ReentrantLock 的功能性相比 synchronized 更丰富。ReentrantLock 底层是基于 AQS 实现的,有一个基于 CAS 维护的 state 变量来实现锁的操作。
1.2.4 ThreadLocal
Java中的四种引用类型:强,软,弱,虚。
User user = new User();
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它始终处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。
其次是软引用,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中,作为缓存使用。
然后是弱引用,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。可以解决内存泄漏问题,ThreadLocal 就是基于弱引用解决内存泄漏的问题。
最后是虚引用,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。不过在开发中,我们用的更多的还是强引用。
ThreadLocal 保证原子性的方式,是不让多线程去操作临界资源,让每个线程去操作属于自己的数据。
示例代码:
static ThreadLocal tl1 = new ThreadLocal();
static ThreadLocal tl2 = new ThreadLocal();
public static void main(String[] args) {
tl1.set("123");
tl2.set("456");
Thread t1 = new Thread(() -> {
System.out.println("t1:" + tl1.get());
System.out.println("t1:" + tl2.get());
});
t1.start();
System.out.println("main:" + tl1.get());
System.out.println("main:" + tl2.get());
}
ThreadLocal 实现原理:
- 每个
Thread中都存储着一个成员变量:ThreadLocalMap。 ThreadLocal本身不存储数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap。ThreadLocalMap本身就是基于Entry[]实现的,因为一个线程可以绑定多个ThreadLocal,这样一来,可能需要存储多个数据,所以采用Entry[]的形式实现。- 每一个线程都有自己独立的
ThreadLocalMap,再基于ThreadLocal对象本身作为 key,对 value 进行存取。 ThreadLocalMap的 key 是一个弱引用,弱引用的特点是:在 GC 时,必定被回收。这里是为了在ThreadLocal对象失去引用后,如果 key 的引用是强引用,会导致ThreadLocal对象无法被回收。
ThreadLocal 内存泄漏问题:
- 如果
ThreadLocal引用丢失,key 因为弱引用会被 GC 回收掉,如果同时线程还没有被回收,就会导致内存泄漏,内存中的 value 无法被回收,同时也无法被获取到。 - 只需要在使用完毕
ThreadLocal对象之后,及时的调用remove方法,移除Entry即可。
二、可见性
2.1 什么是可见性
可见性问题是基于 CPU 位置出现的,CPU 处理速度非常快,相对 CPU 来说,去主内存获取数据这个事情太慢了,CPU 就提供了 L1,L2,L3 的三级缓存,每次去主内存拿完数据后,就会存储到 CPU 的三级缓存,每次去三级缓存拿数据,效率肯定会提升。
这就带来了问题,现在 CPU 都是多核,每个线程的工作内存(CPU 三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致数据不一致问题。
可见性问题的代码逻辑:
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
运行代码可以发现,在主线程中修改了 flag的值之后,子线程 t1 还是处在死循环中,因为子线程没有获取到主存最新的值,t1 获取到的是 CPU 缓存的值。
2.2 解决可见性的方式
2.2.1 volatile
volatile 是一个关键字,用来修饰成员变量。
如果属性被 volatile 修饰,相当于会告诉 CPU,对当前属性的操作,不允许使用 CPU 的缓存,必须去和主内存操作。
volatile 的内存语义:
volatile属性被写:当写一个volatile变量,JMM 会将当前线程对应的 CPU 缓存及时的刷新到主内存中。volatile属性被读:当读一个volatile变量,JMM 会将对应的 CPU 缓存中的内存设置为无效,必须去主内存中重新读取共享变量。
其实加了 volatile 就是告知 CPU,对当前属性的读写操作,不允许使用 CPU 缓存,加了 volatile 修饰的属性,会在转为汇编之后后,追加一个 lock 的前缀,CPU 执行这个指令时,如果带有 lock 前缀会做两个事情:
- 将当前处理器缓存行的数据写回到主内存。
- 这个写回的数据,在其他的 CPU 内核的缓存中,直接无效。
总结:volatile 就是让 CPU 每次操作这个数据时,必须立即同步到主内存,以及从主内存读取数据。
public class Test {
private volatile static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
加了 volatile关键字之后,子线程 t1 成功跳出了循环,程序正常退出。
2.2.2 synchronized
synchronized 也是可以解决可见性问题的,synchronized 的内存语义如下:
如果涉及到了 synchronized 的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从 CPU 缓存中移除,必须去主内存中重新拿数据,而且在释放锁之后,会立即将 CPU 缓存中的数据同步到主内存。
public class Test {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
synchronized (Test.class) {
//...
}
System.out.println(111);
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
对 Test类字节码对象加了锁之后,设计到的静态成员变量就对子线程 t1 可见了。
2.2.3 Lock
Lock 锁保证可见性的方式和 synchronized 完全不同,synchronized 基于他的内存语义,在获取锁和释放锁时,对 CPU 缓存做一个同步到主内存的操作。
Lock 锁是基于 volatile 实现的。Lock 锁内部再进行加锁和释放锁时,会对一个由 volatile 修饰的 state 属性进行加减操作。
如果对 volatile 修饰的属性进行写操作,CPU 会执行带有 lock 前缀的指令,CPU 会将修改的数据,从 CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他 CPU 缓存行中的这个数据设置为无效,必须重新从主内存中拉取。
public class Test {
private static boolean flag = true;
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
lock.lock();
try{
//...
}finally {
lock.unlock();
}
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
2.2.4 final
final 修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取 final 属性,值肯定是一样。
final 并不是说每次取数据从主内存读取,他没有这个必要,而且 final 和 volatile 是不允许同时修饰一个属性的。
final 修饰的内容已经不允许再次被写了,而 volatile 是保证每次读写数据去主内存读取,并且 volatile 会影响一定的性能,就不需要同时修饰。
三、有序性
3.1 什么是有序性
在 Java 中,java 文件中的内容会被编译,在执行前需要再次转为 CPU 可以识别的指令,CPU 在执行这些指令时,为了提升执行效率,在不影响最终结果的前提下(满足一些要求),会对指令进行重排。
指令乱序执行的原因,是为了尽可能的发挥 CPU 的性能。Java 中的程序是乱序执行的。
Java 程序验证乱序执行效果:
public class Test {
static int a, b, x, y;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
a = 0;
b = 0;
x = 0;
y = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
if (x == 0 && y == 0) {
System.out.println("第" + i + "次,x = "+ x + ",y = " + y);
}
}
}
}
单例模式由于指令重排序可能会出现问题:
线程可能会拿到没有初始化的对象,导致在使用时,可能由于内部属性为默认值,导致出现一些不必要的问题。
// volatile 关键字可以屏蔽 JVM 指令重排序
private static volatile Test test;
private Test() {}
/**
* 创建对象正常的流程:
* 1. 堆内存开辟内存空间
* 2. 初始化对象
* 3. 对象变量指向堆内存空间
* 其中第 2、3 步之间可能发生指令重排序
*/
public static Test getInstance() {
// 第 1 个 if 用于判断是否要加锁,避免不必要的加锁造成的性能损失
if (test == null) {
synchronized (Test.class) {
// 第 2 个 if 用于判断单例对象是否已经创建完成,加了 volatile 关键字之后不用担心指令重排序问题
if (test == null) {
// 不加 volatile 关键字可能的情况
// 1. 堆内存开辟内存空间
// 2. 对象变量指向堆内存空间
// 3. 初始化对象
// 这样使用未初始化的对象可能会产生一些问题
test = new Test();
}
}
}
return test;
}
3.2 as-if-serial
as-if-serial 语义:
不论指定如何重排序,需要保证单线程的程序执行结果是不变的。而且如果存在依赖的关系,那么也不可以做指令重排。
// 这种情况肯定不能做指令重排序
int i = 0;
i++;
// 这种情况肯定不能做指令重排序
int j = 200;
j * 100;
j + 100;
// 这里即便出现了指令重排,也不可以影响最终的结果,20100
3.3 happens-before
具体规则:
- 单线程 happens-before 原则:在同一个线程中,书写在前面的操作 happens-before 后面的操作。
- 锁的 happens-before 原则:同一个锁的 unlock 操作 happens-before 对此锁后续的 lock 操作。
- volatile 的 happens-before 原则:对一个 volatile 变量的写操作 happens-before 对此变量的任意操作。
- happens-before 的传递性原则: 如果 A 操作 happens-before B 操作,B 操作 happens-before C 操作,那么 A 操作 happens-before C 操作。
- 线程启动的 happens-before 原则:同一个线程的
start方法 happens-before 此线程的其它方法。 - 线程中断的 happens-before 原则:对线程
interrupt方法的调用 happens-before 被中断线程的检测到中断发送的代码。 - 线程终结的 happens-before 原则:线程中的所有操作都 happens-before 线程的终止检测。
- 对象创建的 happens-before 原则:一个对象的初始化完成先于他的
finalize方法调用。
JMM 只有在不出现上述 8 中情况时,才不会触发指令重排效果。
不需要过分的关注 happens-before 原则,只需要可以写出线程安全的代码就可以了。
3.4 volatile
如果需要让程序对某一个属性的操作不出现指令重排,除了满足 happens-before 原则之外,还可以基于
volatile修饰属性,从而对这个属性的操作,就不会出现指令重排的问题了。
volatile 如何实现的禁止指令重排?
内存屏障概念。将内存屏障看成一条指令。会在两个操作之间,添加上一道指令,这个指令就可以避免上下执行的其他指令进行重排序。