1??观察线程不安全
public class Main {
static int r = 0;
static final int COUNT = 100000;
static class Add extends Thread {
@Override
public void run() {
for (int i = 0; i < COUNT; i++) {
r++;
}
}
}
static class Sub extends Thread {
@Override
public void run() {
for (int i = 0; i < COUNT; i++) {
r--;
}
}
}
public static void main(String[] args) throws InterruptedException {
Add add = new Add();
add.start();
Sub sub = new Sub();
sub.start();
add.join();
sub.join();
System.out.println(r);
}
}
第一次运行结果: -22577 第二次运行结果: 10823 第三次运行结果: -651
理论上,r 被加了 COUNT 次,也被减了 COUNT 次 所以,结果应该是 0,但是结果却和预期的不一致,而且每次的结果还不一样,这就是多线程带来的风险。后面会具体分析原因。
2??线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
ArrayList、LinkedList、PriorityQueue、TreeMap、TreeSet、HashMap、HashSet、StringBuilder等等都不是线程安全的。
Vector、Stack、Dictionary、StringBuffer等是线程安全的,但是线程安全必定会带来性能损失,这几个类都是 Java 设计失败的产品,尽量不要使用。
想要线程安全,程序员在设计的时候尽量就不要带入线程不安全的情况,或者自己解决线程不安全的问题。
3??当前线程不安全的原因
1.站在开发者角度
- 多个线程之间操作同一块数据(共享的),不仅是内存数据
- 多个线程中至少有一个线程在修改(写操作)这块共享数据
即使在多线程的代码中,哪些情况不需要考虑线程安全问题? 1.几个线程之间互相没有任何数据共享的情况下,天生就是线程安全的; 2.几个线程之间即使有共享数据,但只有读操作,没有写操作时,天生也是线程安全的。
2.系统角度
java代码(高级语言)中的一条语句,可能对应了多条指令。
r++ 实质就是r = r + 1
变成指令动作: 1.把 r 的数据从内存中加载到寄存器中 LOAD_A 2.完成对数据加一的操作 ADD 1 3.把寄存器中的值写回到内存中 STORE_A
但是线程的调度是可能发生在任意时刻的,可能在这三条指令其中之一发生调度,但是不会切割指令!
4??线程安全需满足的条件
1.原子性(atomic)
什么是原子性?
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
解释刚才的现象
刚才我们看到的 r++ 或者r-- 就是一个原子性的操作,要么全部完成或全部没完成。 但是实际起来,不能保证原子性,所以就出了错。
为什么 COUNT 越大,出错的概率就越大?
因为 COUNT 越大,线程执行需要跨时间片的概率越大(遇到线程调度的概率越大),导致中间出错的概率越大! 这点和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.
原子性被破坏是线程不安全的最常见的原因
2.内存可见性
可见性指, 一个线程对共享数据的修改,能够及时地被其他线程看到.
前置知识
CPU 为了提升数据获取速度,一般在 CPU 中设置了缓存,因为缓存速度远大于内存速度,内存容量远大于缓存容量。 快和慢都是相对的,这样设计是为了兼顾效率和成本。 Java 内存模型 (JMM)
Java虚拟机规范中定义了Java内存模型. 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
主存储:真实内存 工作内存:CPU中缓存的模拟(不区分几级缓存)
- 线程之间的共享变量存在 主内存 (Main Memory).
- 每一个线程都有自己的 “工作内存” (Working Memory) .
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据,并允许在工作内存中处理很长时间.
- 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程 1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.
- 初始情况下, 两个线程的工作内存内容一致.
- 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步.
甚至在某些情况下,会被优化成完全看不到的结果!
这个时候代码中就容易出现问题.
3.代码顺序性
什么是代码重排序? 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。 现象就是可能重排序过后的执行指令和书写指令不一致。
我们写的程序,往往中间是经过很多环节优化后的结果,并不保证最终执行的语句和我们写的语句一模一样。
JVM规定了一些重排序的基本原则:happens-before规则 简单理解:JVM要求,无论怎么优化,对于单线程,优化前后的结果不应该有变化。但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
举个例子:
重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论
5??解决之前的线程不安全问题
使用 synchronized 锁 这里用到的机制,内容比较多,下一篇博客再讲解。
public class UseSynchronized {
static int r = 0;
static final int COUNT = 1000000;
static Object object = new Object();
static class Add extends Thread {
@Override
public void run() {
synchronized (object) {
for (int i = 0; i < COUNT; i++) {
r++;
}
}
}
}
static class Sub extends Thread {
@Override
public void run() {
synchronized (object) {
for (int i = 0; i < COUNT; i++) {
r--;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Add add = new Add();
add.start();
Sub sub = new Sub();
sub.start();
add.join();
sub.join();
System.out.println(r);
}
}
|