在JAVA程序员圈子,大家都知道掌握并发编程对于一个 Java 程序员是非常重要的。但相对于其他 Java 基础知识点来说,并发编程更加抽象,涉及到的知识点很多很零散,实际使用也更加麻烦。下面主要针对JAVA并发编程中的一个内存可见性问题进行探索。
问题:什么是内存的可见性?一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
我们先来一个12306抢票的例子.public class Ticket {
private long sum = 20000;
public void sendTicket(){
for(int i = 0;i<10000;i++){
//卖出一张票
sum-=1;
}
}
public static void main(String[] args) throws Exception{
Ticket ticket = new Ticket();
//创建售票窗线程1
Thread thread1 = new Thread(new Work(ticket));
thread1.setName("售票窗线程-" + 1);
//创建售票窗口线程2
Thread thread2 = new Thread(new Work(ticket));
thread2.setName("售票窗线程-" + 2);
thread1.start();
thread2.start();
// 等待两个线程执行结束
thread1.join();
thread2.join();
System.out.println("sum="+ticket.sum);
}
}
class Work implements Runnable {
private final Ticket ticket;
public Work( Ticket ticket) {
this.ticket = ticket;
}
@Override
public void run() {
try {
ticket.sendTicket();
System.out.println(Thread.currentThread().getName() + "启动时间是" + System.currentTimeMillis());
} catch (Exception e1) {
e1.printStackTrace();
}
}
}第一次执行结果:
售票窗线程-2启动时间是1574601485313
售票窗线程-1启动时间是1574601485317
sum=2396再执行一个结果:
售票窗线程-2启动时间是1574601475353
售票窗线程-1启动时间是1574601475355
sum=8029
直觉告诉我们应该是0,因为两个售票窗都卖了10000张票,sum 的值就是0,但实际上的执行结果是个 0 到 10000 之间的随机数。啥情况啊这是?为啥和我们想象中的不太一样呢?
我们假设线程 A和线程B同时开始执行,那么第一次都会将 sum=0 读到各自的CPU缓存里,执行完sum-=1之后,各自 CPU缓存里的值都是1,同时写入内存后,我们会发内存中是 19999,而不是我们期望的19998。之后由于各自的 CPU 缓存里都有了 sum 的值,两个线程都是基于 CPU 缓存里的 sum 值来计算。这就是缓存的可见性问题。
这里可能又同学会提出疑问。那如果是都使用的上面的缓存中的值,那应该最后的结果也应该是10000才对呀。没错,理想状态下是这样的。但是我们知道两个线程不是同时启动的,有一个时差。
循环10000 次 sum-=1操作如果改为循环1亿张,总票数改为2亿张,并且使用CyclicBarrier让线程能在同一时刻触发,你会发现效果更明显,最终sum的值接近1亿,而不是 0。
改造之后的代码:public class Ticket {
private long sum = 200000000;
public void sendTicket(){
for(int i = 0;i<100000000;i++){
//卖出一张票
sum-=1;
}
}
public static void main(String[] args) throws Exception{
Ticket ticket = new Ticket();
//创建线程1抢票
CyclicBarrier cyclicBarrier = new CyclicBarrier(2);
//创建售票窗线程1
Thread thread1 = new Thread(new Work(cyclicBarrier,ticket));
thread1.setName("售票窗线程-" + 1);
//创建售票窗口线程2
Thread thread2 = new Thread(new Work(cyclicBarrier,ticket));
thread2.setName("售票窗线程-" + 2);
thread1.start();
thread2.start();
// 等待两个线程执行结束
thread1.join();
thread2.join();
System.out.println("sum="+ticket.sum);
}
}
class Work implements Runnable {
private final CyclicBarrier cyclicBarrier;
private final Ticket ticket;
public Work(CyclicBarrier cyclicBarrier, Ticket ticket) {
this.cyclicBarrier = cyclicBarrier;
this.ticket = ticket;
}
@Override
public void run() {
try {
/**
* CyclicBarrier类的await()方法对当前线程(运行cyclicBarrier.await()代码的线程)进行加锁,然后进入await状态;
* 当进入CyclicBarrier类的线程数(也就是调用cyclicBarrier.await()方法的线程)等于初始化CyclicBarrier类时配置的线程数时;
* 然后通过signalAll()方法唤醒所有的线程。
*/
cyclicBarrier.await();
ticket.sendTicket();
System.out.println(Thread.currentThread().getName() + "启动时间是" + System.currentTimeMillis());
} catch (InterruptedException | BrokenBarrierException e1) {
e1.printStackTrace();
}
}
}
执行结果:线程-2启动时间是1574601120948
线程-1启动时间是1574601120948
sum=99637289
在单核cpu的石器时代,我们所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。
例如在下面的图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量a的值,那么线程B之后再访问变量 a,得到的一定是 a 的最新值(线程 A 写过的值)。
在多核CPU的时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。比如下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量a的操作对于线程B而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
从上面的分析,我们可以知道,多核的CPU缓存会导致的可见性问题。