首先,还是想给大家继续话痨话痨,脱离舒适区,努力坚持下去。我们只要超越百分之80 的人就够了。就像陈皓老师说的,你只看看中国的互联网,你就会发现,他们基本上全部都是在消费大众,让大众变得更为地愚蠢和傻瓜。所以,在今天的中国,你基本上不用做什么,只需要不使用中国互联网,你就很自然地超过大多数人了。 少看一些公众号,知乎,知识星球,微博,每天密切关注大佬,对我们个人的帮助太少了,毕竟所处环境不同。说句不好听的,天天看架构师怎么做怎么做,问题是我们是业务端的,你天天看做价格有啥用。要去找一个业务比你做的好,性能调优比你好的人去学,不要夸大层次去学。 少看头条,抖音,斗鱼,恶搞系列等视频,防止陷入时间黑洞。 少听八卦,职场见闻,社会热点,争议话题等,这些东西跟我们屁关系都没有,还天天跟一群 SB 撕逼破坏自己的心情。最近比特币又火了,那个某某钱包又开始收益了,少关心这些玩意,赚了还想继续,亏了天天心情不好,我要是那会怎么怎么做多好。 知识在深,不在广,不要天天的追求新技术,新知识,持久的才是需要学习的,你觉得进步的那是你的认知,不是知识。 最重要的,少 碎片化学习,你学来的那玩意都联系不起来,有啥用。记不住脑子里面的,建议大家多学读一读书。要系统化的去学习。 学会看文章标题,一些文章一看就是广告。 还有,技术要落地,不是你今天学个 dubbo、netty 学会了就会了,没有落地场景,你学的那玩意是应付面试的,不是学习的。
这篇文章,我们将给大家来讲解引起我们并发问题的三大因素--— 有序性、可见性、原子性。这三个问题是属于并发领域的所以并不涉及语言。
首先,我们来聊聊什么是安全性。
一个对象是不是线程安全的,取决于它是否被多个线程访问,如果是单线程,它处于同步状态对对象可变状态的访问,所以一定是线程安全的。如果在多线程情况下,没有协同对可变状态的访问,那么就是非安全。
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替指向,并且在主调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的 –《Java 并发编程实战》
通过上面的引用,我们知道了:
上面讲到的无状态对象是指 方法的计算过程只存在于线程栈上的局部变量中,并且只能有当前正在执行的线程访问。比如:
private Integer sum(int num) {
return ++num;
}
说到原子性,可能大家的第一反应就是 i++ 和 ++i 了。
jvm 是如何执行 i++的,我们接着来看。
public class AtomicIntegerTest {
public static void main(String[] args) {
AtomicIntegerTest atomicIntegerTest = new AtomicIntegerTest();
atomicIntegerTest.sum(10);
}
public int sum(int i) {
i = i++;
return i;
}
}
然后我们通过 javap 看它的反编译后的文件 如下
Classfile /D:/Work/math-teaching/src/test/java/base/AtomicIntegerTest.class //Class文件当前所在位置
Last modified 2019-7-2; size 384 bytes // 最后修改时间,文件大小
MD5 checksum 48f1e270d21b6836df2a88c8545dd2fd // md5 值
Compiled from "AtomicIntegerTest.java" //编译自哪个文件
public class base.AtomicIntegerTest // 类的全限定名
minor version: 0 // jdk次版本号
major version: 52 // jdk主版本号。
flags: ACC_PUBLIC, ACC_SUPER //为Public类型
Constant pool:
#1 = Methodref #5.#16 // java/lang/Object."<init>":()V
#2 = Class #17 // base/AtomicIntegerTest
#3 = Methodref #2.#16 // base/AtomicIntegerTest."<init>":()V
#4 = Methodref #2.#18 // base/AtomicIntegerTest.sum:(I)I
#5 = Class #19 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 sum
#13 = Utf8 (I)I
#14 = Utf8 SourceFile
#15 = Utf8 AtomicIntegerTest.java
#16 = NameAndType #6:#7 // "<init>":()V
#17 = Utf8 base/AtomicIntegerTest
#18 = NameAndType #12:#13 // sum:(I)I
#19 = Utf8 java/lang/Object
{
public base.AtomicIntegerTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
// stack 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1
// locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。
//args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable: // 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class base/AtomicIntegerTest
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: bipush 10
11: invokevirtual #4 // Method sum:(I)I
14: pop
15: return
LineNumberTable:
line 6: 0
line 7: 8
line 8: 15
public int sum(int);
descriptor: (I)I
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iload_1 //将指定的int 型本地变量推送至栈顶
1: iinc 1, 1 //将指定 int 型变量增加指定值(也就是我们的 ++ i)
4: istore_1
5: iload_1
6: ireturn //从当前方法返回 int
LineNumberTable:
line 11: 0
line 12: 5
}
SourceFile: "AtomicIntegerTest.java"
看完上诉的 class 文件,大家应该明白了, i = i ++ 这一操作 有 3 个步骤。
上述情况在单线程下是 OK 的,但是在多线程下 一个简单的 i = i ++ 使用 3 条 CPU 指令,这三条指令由于操作系统 线程 时间分片的原因,多个系统进行执行的话就会带来意想不到的变故。
在操作系统由于 IO 阻塞等问题太慢的时候,出现了多线程,多线程的核心就是轮训 cpu 执行,而这个执行的时间就是 时间片(也就是我们通常说的 任务切换时间)
时间片又称为“量子(quantum)”或“处理器片(processor slice)”是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间(在抢占内核中是:从进程开始运行直到被抢占的时间)。 — 维基百科
其实说白了就是在我们的现代操作系统中,允许同时运行多个进程,而这个同时是在我们用户的使用感知上,而在CPU中则是不停的在切换。毕竟因为时间片太短了。
时间片的分配由 OS 的调度程序分配给各个进程,当然进程中也有各个线程。然后内核就通过这个调度程序分配相当的初始时间片,然后每个 进程/线程 轮训去执行相应的时间,至于由哪个进程/线程去执行,则由调度程序通过线程优先级和调度算法去判断。当时间片执行完的时候,内核又会重新为每个进程去计算并分配新的时间片,如此反复。
所以我上边废话了一堆,其实就是一句话,因为多线程的缘故,一个Java 语法被分割成了 3 条 CPU 指令,单线程情况下,当然 OK。多线程情况下 ,因为都要抢占来执行 变量,所以就无法正确的执行。当然,如果多个线程都知道上一个线程处理的结果,是不是就正确执行下去了呢? -– 对的。就是我们下面讲到的可见性。
在计算机发展的过程中,因为我们的程序大部分都是要操作内存的,有些程序还要访问 IO,比如我们的 文件的读写,微服务的通信等,所以 CPU、内存等硬件设备一直在不停的升级调优,但是这里就遇到了个问题,设备之间的差异性。很正常的,电脑都有 SSD 和机械硬盘,同样的 CPU ,搭配不同的硬盘设备都有不同的体验效果,这个时候就是差异性了。比如 电脑是 16G 内存,那么就算你 CPU 也是顶配,但是硬盘是个普通的机械硬盘,那么你得程序处理性能依旧有限,毕竟IO读写变成了瓶颈。所以不能单方面的提高某一设备。
所以各位大佬为了提高性能,就想出了各种法子,然后我们编写多线程的就学的很恶心了:
之后的文章,涉及到的知识点都或多或少的讲解下,毕竟大家是来学习的,不是来当韭菜
图片来自CSDN
CPU 分为三级缓存。其中: 一级缓存 是内置在 CPU 内部和 CPU 以相同速度运行的,可以有效提高 CPU 的运行效率的。所以如果这个缓存足够大,我们程序的内存数据都放这里面,跟坐火箭一样快,但是想想就好了,因为这个缓存受到了 CPU 结构限制,一般都很小的。 二级缓存 该缓存位于 CPU 和内存之间的临时存储器中,容量比内存小,但是交换速度还是很快。二级缓存中的数据是内存数据中的一部分,是短时间内 CPU 马上要访问的数据。、 三级缓存 为了读取二级缓存未命中的数据设计缓存,就是二级缓存没有的数据在三级缓存中存放着(还有 5% 需要从内存读取)。原理是使用比较快的存储设备从慢存储设备中读取数据 copy 到当前,当有需要的时候再读取。和我们 Java 经常说的懒加载相似。 一级缓存在 CPU 中,需要 2~4 个时钟周期,二级缓存需要 10 个左右时钟周期,三级缓存则需要 30~40 个时钟周期。 今天的CPU将三级缓存全部集成到CPU芯片上。多核CPU通常为每个核配有独享的一级和二级缓存,以及各核之间共享的三级缓存。
再回到 我们的 Java 中,比如下面这段代码
public class AtomicIntegerTest {
private static long num = 0;
private void add() {
for (int i = 0; i < 10000; i++) {
num += 1;
}
}
public static long calc() throws InterruptedException {
final AtomicIntegerTest test = new AtomicIntegerTest();
// 执行 add() 操作
Thread th1 = new Thread(() -> test.add());
Thread th2 = new Thread(() -> test.add());
// 启动线程
th1.start();
th2.start();
// 等待
th1.join();
th2.join();
return num;
}
public static void main(String[] args) throws InterruptedException {
calc();
System.out.println(num);
}
}
输出的结果却是 11412 、 12581。每次运行的结果还不相同。
其实说白了 就是 th1 和 th2 同时开始执行,首次的时候都是吧 num读到 缓存中(这里我们忽略三级缓存问题),执行完 num += 1 之后,各自的缓存中值都是1,同时写入内存,这个时候的内存还是1,而不是我们期望的 2。这就是缓存可见性的问题。
如果循环竞争的次数很小,比如 10次,那么结果可能还是对的,但是次数增大,那么结果差的就越大。
这里解释下,线程运行的时候,数据的计算是在 CPU 缓存中,而线程的数据 则是在 内存中,CPU缓存不在内存中,上面已经提到了,它是一块很小的芯片,至于什么时候把 CPU 中的数据写到缓存中,就要看 CPU 的心情了,所以 Java 中有一个volatile 声明,强制刷新到内存中,这个就是 volatile的写入屏障问题,也是所谓happen-before问题,我们在后面的时候会继续讲到。
在我们依赖的 jvm中,还有个更坑的问题,就是有序性,也就是指定重排序。程序为了优化性能,有的时候会把高级程序中的代码体进行重新排序,比如
int a = 1;
int b = 2;
编译后就变成了
int b = 2,;
int a = 1;
这种只是调整了语句,并不会改变程序的最终结果。
但是在我们的并发编程中,就出现了意想不到了。
首先大家要记住一点
不能通过 happens-before 原则推导的,JMM允许重排。
--— 深入理解 Java 虚拟机
在许多情况下,对程序变量(对象实例字段,类静态字段和数组元素)的访问可能看起来以与程序指定的顺序不同的顺序执行。编译器可以自由地使用优化名称中的指令顺序。处理器可能在某些情况下不按顺序执行指令。可以以不同于程序指定的顺序在寄存器,处理器高速缓存和主存储器之间移动数据。
例如,如果一个线程写入字段a然后写入字段b,并且b的值不依赖于a的值,则编译器可以自由地重新排序这些操作,并且缓存可以自由地将b刷新到main记忆之前的。有许多潜在的重新排序源,例如编译器,JIT和缓存
编译器,运行时和硬件应该合谋创建as-if-serial (如果串行执行)语义的假象,这意味着在单线程程序中,程序不应该能够观察重新排序的影响。
public class ReorderTest {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
public static void main(String[] args) {
ReorderTest reorderTest = new ReorderTest();
Thread th1 = new Thread(() -> reorderTest.writer());
Thread th2 = new Thread(() -> reorderTest.reader());
System.out.println("x = " + reorderTest.x);
System.out.println("y = " + reorderTest.y);
}
}
上诉代码输出的是
x = 0
y = 0
比如 (臭名昭着的)双重检查锁定(也称为多线程单例模式)是一种旨在支持延迟初始化同时避免同步开销的技巧。
// double-checked-locking - don't do this!
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
假设有 两个线程 A 、线程 B 在同一时间调用 getInstance() ,那么首先会判断 instance 是否等于 null,假如此刻 是null 的话,开始竞争锁,如果 线程 A 竞争到锁了,那么进行初始化操作;执行完成后,唤醒线程 B。此时线程 B 获取到锁,然后继续判断 instance == null ,线程A 已经初始化过了,所以线程 B 此时会直接返回。理论上来说,是没有问题的。
其实问题就出现在了 new Something() 这个方法上。我们认为的过程应该是这样:
但是可能实际上是这样:
还是继续上面,如果 线程 A 分配了内存,以及分配了引用,但是没有调用构造函数。此时线程B 进行 ,它看到 instance 不是null,就直接返回了,而此时并没有完成构造函数的调用。这里还有一个 happens-before 原则
图片来自 www.logicbig.com
Happens-before定义程序中所有操作的部分排序。为了保证执行操作Y的线程可以看到操作X的结果(X和Y是否出现在不同的线程中),X和Y之间必然存在一个先发生的关系。在没有发生 - 之前排序的情况下在两个操作之间,JVM可以根据需要自由重新排序
Happens-before 之前发生的不仅仅是'时间'中的动作重新排序,而且还保证了对内存的读写顺序。执行写入和读取到内存的两个线程可以在 CPU 时钟时间方面与其他操作保持一致,但可能看不到彼此一致的更改(内存一致性错误),除非它们之前发生关系。
The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model. This provides a great deal of freedom for the implementor to perform a myriad of code transformations, including the reordering of actions and removal of unnecessary synchronization. --- Java语言规范 -基于 Java8
可以看出在规范中明确提出了,只要程序的所有结果可由内存模型预测出,那么实现者就可以自由地生成它喜欢的任何代码。包括重新排序动作和删除不必要的同步。而这也是Java 通过内存模型定义及其底层的关系。也是 一次编写,随从运行的核心。