感谢Java知音小知同学提供的渠道,今天来总结下Java内存模型和乐观锁ABA问题。

内存模型

众所周之,Java是有个内存模型的,也就是堆区,栈区,本地方法区,方法区,程序计数器。
其目的是为了让Java能够在不同平台运行的效果一致。

这个只是其表面状态,就像面向对象编程,是给你看到的一层假象,就算你是面向对象编程,CPU执行命令时还是顺序执行,遇到特定指令跳到对应位置执行,执行结束后返回指令的下一行。

那其真谛是什么?
OK,接下来我们一起给JVM内存模型脱一脱衣服。

与内存聊天

首先cpu在执行中,执行的是高速缓存中的记录,这个高速内存就是买cpu时很关键的一个参数叫缓存,一般现在CPU都拥有三级缓存,L1、L2、L3。而高速缓存中的记录是从主内存中拿到的副本,也就是拷贝了一份。

原因是我们的CPU操作太快了,快到我们的内存根本跟不上CPU的速度,就更别提硬盘了。那CPU厂商为了能够让CPU充分利用运算能力,不去等其他硬件的配合,于是CPU就有了高速缓存,越接近CPU那一级的缓存越快,同理也越昂贵。

当CPU需要一条数据时,会把数据加载到高速缓存中,这个过程并不是把内存中的数据“剪切”到高速缓存中的,他是拿了这份数据的副本进行处理,处理完成后再回写到内存中,再由程序决定是否需要写入硬盘持久化。

那这个过程中CPU都做了哪些操作,JVM又配合了什么呢?
首先所有线程栈和堆会被保存在缓存里面,部分可能会出现在CPU缓存中和CPU内部的寄存器里面

  1. lock(锁定):作用于主内存的变量,把一个变量标识变为一条线程独占状态
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中

这段话我相信大家在很多网站上也见过,可能每个人描述不一样,但大同小异。
需要注意的是,如果没有运算后第一步 赋值到工作区的操作 是不允许写入主内存区
其作用的顺序为-> Lock(锁定) -> Read(读取缓存区) -> Load(加载到工作内存) -> Use(使用) -> Assign(回写到工作内存) -> Store(存储到缓存区) -> write(回写到主内存) - Unlock(解锁)

笔记:线程的本地内存,指的是cpu的缓存和寄存器,JVM的静态内存模型,他只是一种对内存模型的物理划分而已,只局限在内存,而且只局限在JVM的内存,线程共享变量一定是在主存中的

所以共享变量的流程一定是线程A从本地存储写入变量到主存,线程B去读这个变量
这问题不就出现了吗。 如果B刚读完这个变量到自己的本地内存中,A把这个变量改了,该怎么办?

线程并发处理

上面提到的问题,就涉及到了并发处理了,数据不一致,下面以程序举例子(代码有精简):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static int clientTotal = 50000;
private static int count = 0;

ThreadFactory factory = new ThreadFactoryBuilder().setNameFormat("test-%d").build();
ExecutorService service = new ThreadPoolExecutor(5, 200, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingDeque<>(1024), factory, new ThreadPoolExecutor.AbortPolicy());
for (int i = 0; i < clientTotal; i++) {
service.execute(() -> {
add();
});
}

private static void add(){
count ++;
}

这个程序一定是有问题,可以拿出来试一下,如果电脑性能特别好,可以多尝试几次,相信最终结果肯定不是50000。
因为我们是多线程程序,线程并发同时处理,假设A、B同时拿到0这个值,那计算完都是1,回写到主存中都是1,也就是一次计算失效了。
为了解决这个问题,我们可以通过Atomic包下的类来处理,Atomic包下的工具类实现了CAS[CompareAndSwap]算法。

CAS的主要原理为:
获取主存的值,do while方式循环判断当前要被更改的值是否与线程处理前获取到的值相同,
相同则允许更改,不相同循环重新在底层内存中取值计算

CAS只是其中一种处理方式,另外还有synchronized同步、Lock锁、wait和notify、特殊域变量volatile、ThreadLocal局部变量、阻塞队列等等方式,印象中Java并发容器中就有使用阻塞队列的容器,具体是哪个有点记不清了,有时间拿出一块时间专门写一个并发容器的总结。

CAS这种方式并不是很好,原理有二,第一,CAS会占用资源去做循环,第二,会发生ABA问题,我个人很讨厌用这种名次来说事,但这个我还没有找到其他方式来描述

CAS中ABA问题

所谓ABA问题,回过头看CAS原理:do while方式循环判断当前要被更改的值是否与线程处理前获取到的值相同
那问题就来了,仔细细考一下,如果我A线程计算特别快,同样一次计算,A线程用时2ms,B线程用了4ms
这时候等B执行完,A都执行两次了,如果初始值为0,A线程第一次计算,+1,第二次计算 -1,此时主存中的结果仍为0
A线程第二次计算结束后,B线程终于执行完了,一看,主存中还是0,跟他拿到的计算前的值相同,乐呵呵的就把他计算的值放主存里了

打眼一看,这个结果貌似也还可以接受。但如果遇到特殊场景,需要针对这个问题进行处理.
比如有个变量是记录发送消息个数的,A线程发送,B线程统计,AB线程恰好第十条数据同步,A线程发送第10条数据,失败了,第十一条数据成功,此时记录消息的变量为10,线程B记录完第9条数据,恰好读到第十条数据,此时B线程认为,A线程一共发了10条,10条成功。那这个情况是不允许发生的。 —场景仅供参考

为了解决这个问题,最简单的办法还是去Atomic包,去找AtomicXXXXXXXFieldUpdater,这个处理方式为,会在存储时增加一个东西,就是版本,比较值是否相同时还会比较小版本是否一致

但这个弊端就是,方法在高并发情况可能会增加CAS失败率,造成更多循环,浪费资源,这个是没法避免的

总结

其实线程这块,主要就那么几块,内存,内存包括安全发布,可见性,还有J.U.C,原子性包,线程那几种方式再加上信号量那些概念,同步相关,包括synchronized同步,Lock锁,上面提到的那些,剩下的也就没啥了,搞懂这些,估计在遇到啥也不是问题