线程安全
线程安全的定义
线程安全:某个代码无论是在单线程上运行还是在多线程上运行,都不会产生bug.
线程不安全:单线程上运行正常,多线程上运行会产生bug.
观察线程不安全
看看下面的代码:
public class ThreadTest1 {
public static int count = 0;
public static void main(String[] args)throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
count++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
count++;
}
}, "t2");
t1.start();
t2.start();
t1.join();
System.out.println(count);
}
}
按照常理来讲,运行的结果应该是20000,但让我们来看看实际的运行结果:
显然结果与我们预期的不一致,但为什么会出现这种问题呢?
让我们来看一下线程不安全的原因:
线程不安全的原因
重点:线程调度是随机的
1.根本原因:操作系统上线程是"抢占式执行",而且是"随机调度"的,执行顺序会有很多变数.(罪魁祸首)
2.代码结构:多个线程同时修改一个变量(1. 一个线程修改一个变量(没事) 2.多个线程同时读取一个变量(没事) 3.多个线程同时修改不同的变量(没事))
3.直接原因:上述线程的修改操作本身不是"原子的",比如count++这条语句,它本身包含多个cpu指令(这个例子后面会详细讲).执行了一半可能会调度走.
4.内存可见性问题(例子里的代码还没有),后面的文章会讲.
5.指令重排序问题
分析例子代码中的问题
这个问题就主要出现在count++这条语句中.它本身包含这些cpu指令:LOAD,ADD,SAVE
让我们回顾一下这几条指令的含义:
(1)load:从内存中读取数据到cpu的寄存器
(2)add:把寄存器中的值+1,
(3)save:把寄存器的值写回到内存中.
因此count++这条语句的执行的流程如下:
这是一个count++的执行流程,但是在多进程程序中,这三条指令一定会连贯执行吗(规范的按照一个load->add->save执行)? ,留着这个问题,来看看后面的内容:
修改共享数据
在例子中,显然是符合多个线程修改同一个变量的.
上面线程不安全的代码中,涉及到多个线程对count变量进行的修改.
此时这个count是一个多线程都能访问到的共享数据,因此t1和t2都可以对count进行修改.
原子性
什么是原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人.如果没有任何机制保证,A进入房间之后,还没有出来;B是不是也可以进入房间,打断A在房间中的隐私.这个就是不具备原子性的.
不保证原子性会给多线程带来什么问题
如果一个线程正在对一个变量操作,中途其它的线程插进来了,如果这个操作被打断了,结果可能就是错误的.
这点也和线程的抢占式调度密切相关,如果线程不是"抢占"的,就算没有原子性,也问题不大.
综合以上,我们可以得到引起问题的原因:共享数据的修改以及数据并非原子的.
通过下面这个图就可以看出来:
等等还有很多种执行顺序(无数种).
比如图二:由于t2的load抢占在t1的add前执行,因此导入时count值都一样,那么执行的结果最后就是+1,而不是理想中的各自线程都给count+1,最后执行完两个就是+2了.那么有没有一种情况执行结果是正常的,当然有:
类似这种每个线程执行时,三条指令都是在一块的,这种运行是正确的,那么有没有一种方法能按照这样运行呢?有的.
只要将count++操作上锁,使得这三条一起指令执行完之后,才会执行下一个操作.
有时也把这个现象叫做同步互斥,表示操作是互相排斥的.
解决上面的问题
public class ThreadTest {
public static final Object locker = new Object();
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
//进入大括号会上锁
synchronized (locker) {
count++;
}//出大括号会解锁
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
这里用到的机制(synchronized)后面的文章会解释.
可见性
可见性指,一个线程对共享变量值的修改,能够及时被其它线程看到.
Java内存模型(JMM) :Java虚拟机规范中定义了java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
线程之间的共享变量存在主内存(可以看作为上面的内存)
每一个线程都有自己的工作内存(并不是真正的内存,可以看作为上面的cpu寄存器(也有可能是cpu缓存,不过都差不多))
当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存种读取数据.
当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存.
(1)初始情况下,两个线程的工作内存一致
(2)一旦线程1修改了a的值,此时主内存并不一定可以及时同步过来(是在寄存器中改动的,因为寄存器比较快)
此时引入了一个问题:
为什么要在主内存和工作内存种麻烦的拷来拷去?
因为CPU访问自身寄存器的速度以及高速缓存的速度,远远超过访问内存的速度(快了几千至上万倍)
比如某个代码种要连续10次读取某个变量的值,如果10次都从内存中度,速度是很慢的.但如果只是第一次从内存中读,读到的结果缓存到CPU某个寄存器中,那么后面9次就不需要从内存中读了,效率就大大提高了.
那么问题又来了,既然寄存器速度这么快,还要内存干嘛?
贵!
后面我们将用更详细的方法解决线程安全问题,敬请期待.