一、Show me your code
先考大家一个问题,如下这段代码,多线程执行有没有问题。
public class Main {
static int num = 0;
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 5000; i1++) {
num++; // 共享资源
}
}).start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num); // 打印结果
}
}
有经验的人很容易知道,num 值的打印结果是不确定的。这就是 Java 多线程访问共享资源 的问题。接下来试着解释一下,为什么会出现问题。
1.1 现象解释
Java多线程内存模型,是Java屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。(说白了就是定义程序中变量的访问规则)
Java内存模型
工作内存:每个线程都有自己的工作内存,工作内存保存了线程需要的变量在主内存中的副本。线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中变量。
关于主内存 和 工作内存的交互 协议,java虚拟机定义了8中操作来完成,且每种协议必须是 原子性 的。
- lock: 作用于主内存变量,它把一个变量标识为一个线程独占的状态。
- unlock:作用于主内存变量,它把一个处于 lock 状态的变量释放出来,释放后才能被其它变量锁定。
lock 和 unlock 对应的字节码层面就是 monitorcenter、monitorexit 指令,对应Java层面就是 synchronized 代码块)
- read:作用于主内存变量,把一个变量从主内存传输到线程工作内存,以便以后的 load 使用
- load:作用于工作内存变量,把read到的变量放入工作内存的变量副本中
- use:作用于工作内存变量,把工作内存中的变量传递给执行引擎。(每当虚拟机遇到一个需要使用变量值的字节码指令时会执行这个操作)
- assign:作用于工作内存变量,把一个从执行引擎收到的值赋值给工作内存中的变量。(每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作)
- store:作用于工作内存变量,把值从工作内存传输到主内存,以便后续write操作使用
- write:作用于主内存变量,把store 得到的值赋值给主内存中的变量
jvm还定义了很多规则,比如:对一个变量执行 unlock之前,必须先把变量同步回主内存中(执行store、write)。
通过以上讲解,应该知道示例代码为什么多线程是不安全的:因为可能会有多个线程同时把 num 读入到了 工作线程,这样在其它线程把新的 num 值写回主内存的时候,有些线程的工作内存的值还是旧的,所以就会出问题。
Java内存模型是围绕着在并发开发过程中如何处理 原子性、 可见性 和 有序性 这3个特征来建立的。
原子性:体现在 read/load/assign/use/store/write/lock/unlock
可见性: 体现在volatile 、synchronized(对一个变量执行 unlock之前,必须先把变量同步回主内存中) 、final
有序性:volatile(放弃指令重排,因为内存屏障保证了,必须先把工作内存写回主内存才可以进行接下来操作) synchronized(一个时刻只允许一条线程对其进行lock操作)
二、解决办法
知道了,示例代码的问题所在,Java提供了哪些同步机制呢。
2.1 synchronized
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 5000; i1++) {
synchronized (Main.class){ // synchronized
num++;
}
}
}).start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num);
}
Java层面的synchronized,在字节码层面是 monitorcenter 和 monitorexit 指令,虚拟机层面是 lock 和 unlock,保证了代码块的原子性、可见性和 有序性,即线程安全。(根据前面的描述,这里应该很好理解)synchronized 会让多个线程去争抢某一个对象的锁,谁先争抢上就是谁的。(如本例中的 Main.class 类对象。如果两个线程争抢的不是同一个对象的锁,那就不会互斥同步。)
需要注意的是:synchronized 的实现时 互斥同步,即同步锁只能有一个线程获得,未获得锁的线程会阻塞,所以又叫阻塞同步。是一种 悲观锁,悲观的认为程序中并发情况很严重。但是 synchronized涉及线程的阻塞和唤起,涉及用户模式和内核模式的转换( java线程会映射成操作系统线程 ),代价高。在一些简单的同步操作中(如本例的 num++,且此时假设并发低的情况下),可能稍微等一下就能获取对象锁,这时候如果进行线程的 阻塞 和 唤醒 的话,消耗就相对比较不划算。这时候就有另外一种同步机制,可以解决这个问题,那就是 CAS(Compare and swap),CAS 也是 原子操作,涉及到的API,就是java.util.concurrent 包下的 atomic 类。我们来看一下怎么使用这些类实现线程安全。
CAS 是后来随着硬件指令集的发展,才有的一个另外的选择。因为我们需要 compare&swap 这个操作具备原子性。如果再使用互斥同步来保证,那就失去意义了,所以只能靠硬件来保证。
2.2 Atomic 原子类
static AtomicInteger aNum = new AtomicInteger();
private static void threadUpdate2() {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 5000; i1++) {
aNum.incrementAndGet(); //原子操作
}
}).start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(aNum.get());
}
CAS 是 乐观锁,乐观认为程序中并发情况不严重。 CAS 指令需要有3个操作数,分别是内存位置(V)、旧的预期值(A) 和 新值(B)。当且仅当V 符合旧预期值 A时,才会用B去更新V的值,否则不执行更新。这个操作是个原子操作。
关于 非同步阻塞:基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据,那就操作成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施(最常见的就是不断重试,直到成功为止),这种乐观并发策略不需要把线程挂起,因此成为非阻塞同步。
2.3 synchronized 和 CAS 区别
2.3.1 synchronized 是悲观锁,悲观的认为程序中并发情况严重; CAS 是乐观锁,乐观认为程序中并发情况不严重。
2.3.2 CAS 只能保证一个变量的原子性操作,对于代码块无能为力。
2.3.3 CAS优点:在低并发的环境下,使用效率高;缺点:占用CPU资源。 synchronized 缺点:涉及线程的阻塞和唤起,涉及用户模式和内核模式的转换,代价高。
2.3.4、CAS ABA 的问题。
ABA问题描述:此时共享内存区域的值为A, Thread1 想把的值由 A 变为 C (操作一); Thread2 有两个原子操作: 1、想把值由 A 变为 B (操作二) 2、把值由 B 变为 A (操作三)。 在线程竞争资源的过程中,有可能出现: 操作二 、 操作三 、 操作一 的顺序。 对于操作一来说 compareAndSet() 来说没有任何问题,因为此时内存值确实是A, 但 操作一 不知道在这之前可能有人已经动过了内存的值。 在一般情况下ABA也不会影响最终的结果,在特殊的情况下,例如:单链表 会出问题。
ABA的问题参考: https://hesey.wang/2011/09/resolve-aba-by-atomicstampedreference.html?spm=a2c6h.13066369.0.0.5d5036d3vV3QAZ
Java 虚拟机提供的同步机制:volatile 、互斥同步(阻塞同步)、非阻塞同步(CAS机制) 等。volatile 可以说是 Java 虚拟机提供的 最轻量级 的同步机制,但它并不容易完全被正确、完整的理解,以至于很多程序员都不习惯去使用它,而是一律使用 synchronized。
volatile语义:
1、保证此变量对所有线程的可见性。 当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量做不到这一点,需要一个线程从工作内存同步到主内存,再由另外一个线程从主内存读取到工作内存才可见。(其实volatile变量也会存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况)
2、禁止指令重排优化 有volatile 修饰的变量,变量赋值后会插入一些其他的汇编指令(字节码和汇编指令可能会有很多不同),这相当于一个内存屏障,不会把后边的指令重排序到内存屏障之前。内存屏障的处理是 使本CPU的Cache写入内存,该写入动作会引起别的CPU的cache无效。意味着所有之前的操作都已经执行完成,这样便形成了"指令重排排序无法越过的内存屏障",可以参考 单例模式中 Double Check的实现。
volatile 与 锁之中选择唯一的依据仅仅是 volatile 语义能否满足使用场景的需求。









网友评论