一、并发编程问题的根源
- CPU缓存导致数据可见性问题:在计算机组成原理中为了提升整个计算机系统的效率,引入了缓存的概念。在CPU高速计算的时候首先会从其内部的寄存器或拿所需要的数据,当寄存器中的数据没有被命中的时候,才会从主存中获取数据。在单核CPU的时代,这个的做法似乎没有问题因为一个线程对数据的操作对后续需要读或者写这个数据的线程是可见的。但是在多核的CPU中,由于数据被拷贝到各自的缓存中,然后在对数据进行计算,在多个线程并发执行的环境下会存在数据修改丢失的问题。这也就由于CPU的缓存导致数据的可见性不一致。下面是两个线程,每一个线程对共享变量加10000次,但是实际结果并不是20000而是10000到20000之间的随机数。
public class Test {
private long count = 0;
private void add() {
for (int i = 0; i < 10000; i++) {
count++;
}
}
private long calc() throws InterruptedException {
Thread t1 = new Thread(() -> add());
Thread t2 = new Thread(() -> add());
t1.start();
t2.start();
t1.join();
t2.join();
return count;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Test test = new Test();
System.out.println(test.calc());
}
}
}
执行十次的结果如下:
12465
11650
15088
14533
13880
20000
19051
14792
20000
20000
- 由于CPU切换导致原子性问题:代码在运行的时候CPU会给每一个任务分配对应的时间片,在时间片使用完成之后就会把CPU的使用让出来。但是由于在计算的时候需要将数据拷贝到CPU内部高速寄存器缓存。但是在计算刚完成一半的时候分配的时间片被使用完成,在这个时候当前线程只能让出CPU的使用权给其他的线程去使用。但是其他的线程计算的时候也是基于相同的主存数据做计算,但是并不知道这份数据已经被拷贝到计算。拷贝还是主存中没有被更新的数据。最终会导致只要其中任何一个线程的数据先写回主存,后面线程计算的结果都会覆盖之前的计算数据导致数据的丢失。
- 由于JVM指令优化带来有序性问题:编译器在优化的时候如下的语句
a=1;b=2
。可能会首先执行b=2
,然后在执行a=1
。这样的优化最终不会影响程序执行的最终结果。但是有的时候优化会带来意想不到的问题。
public class Singleton {
private Singleton instance;
private Singleton(){}
public Singleton getInstance(){
// 在进来的时候首先判断当前实例是否已经被创建,假如已经创建则直接返回
if (instance == null){
// 防止多个线程同时创建多个实例,所以需要在次加锁。只要锁的是同一个对象即可。在这里锁的是当前类的class
synchronized (Singleton.class){
// 在这里是防止当第二个线程进来的时候再一次创建实例
if (instance == null){
instance = new Singleton();
}
}
}
// 返回实例
return instance;
}
/*正常的执行的逻辑如下:
* 1 先分配一块内存
* 2 在这块内存上初始化对象
* 3 然后在把这个对象赋值给instance
*
* 但是优化之后的逻辑如下:
* 1 首先分配一块内存
* 2 把instance指向这块内存
* 3 然后在这块内存上初始化对象
*/
}
如上面的代码所展示的那样,最终使用的时候带来最讨厌的空指针异常。
4. 总结一下:在多线程的环境下会导致线程安全的根源如下:可见性、原子性、有序性
二 、解决方法
- 既然已经知道导致多线程问题的原因,那解决且不是简单了很多。首先看看如何解决原子性。首先分析导致原子性的原因:是多个线程交替的对同一个变量的进行了修改。那解决的方法就是在同一个时刻只能有一个线程对变量进行修改。在操作系统中这样的资源称为临界资源。要访问临界资源就需要互斥。单次只能有一个线程可以访问。这样就可以有效的保证原子性。那在java中大家首先想到就是
synchronized
。首先看看,synchronized
的几种用法:
public class X {
// 修饰静态方法 ===> 相当于锁X.class
public synchronized static void add(){
}
// 修饰普通的方法 ====> 相当于锁 this对象
public synchronized void update(){
}
//锁对象 锁需要创建的对象
Object object = new Object();
public void delete(){
synchronized (object){
// 执行删除逻辑
}
}
}
那利用锁解决两个线程对同一个变量加一万次只需要做简单的修改即可,如下所示
private synchronized void add() {
for (int i = 0; i < 10000; i++) {
count++;
}
}
这样不管运行多少次得到的都是相同的结果。所以要解决原子性只需要保证计算的中间状态对外是不可见的。
2. 解决 可见性、有序性。解决上述的两个问题需要借助java提供的内存模型。具体可以描述如下的几项Happens-Hefore
规则:
- 程序的顺序性规则。在单线程中按照程序执行的规则,在前面的操作
Happens-Hefore
后续的任意规则。也就是程序对前面某个变量的修改对后续的程序都是可见的。如下代码所示:
public class HappensBefore1 {
private int x = 0;
private volatile boolean v = false;
public void write(){
x = 42;
v = true;
}
public void read(){
if (v){
System.out.println(x);
}
}
public static void main(String[] args) {
/*
* 主线程对x的操作对后面的read操作是完全可见的
*/
HappensBefore1 hp1 = new HappensBefore1();
hp1.write();
hp1.read();
}
}
在主线程中前面修改的数据,完全对后面是可见的。
- volatile 变量规则。对一个volatile变量的写操作。
Happens-Hefore
对这个变量的读操作。这个看起来好像是没有使用缓存。 - 传递规则。如果A
Happens-Hefore
B,BHappens-Hefore
C。那么AHappens-Hefore
C。如下所示:
public class HappensBefore3 {
/*这条规则是指:如果A happen-before B,B happen-before C。那么A happen-before C
*/
private int x = 0;
private volatile boolean v = false;
public void write(){
x = 42;
v = true;
}
public void read(){
if (v){
System.out.println(x);
}
}
public static void main(String[] args) {
final VolatileExample example = new VolatileExample();
new Thread(() -> example.write()).start();
new Thread(() -> example.read()).start();
}
/*在上述的程序中执行的规则如下:
* 1 在write的操作中。对x的赋值先于v=true。这个操作之前发生。这个可以根据规则一
* 2 在线程2中。读取了v这个变量的。根据规则二可以得知。对这个变量的写操作happen-before读操作。所以可以读取这个变量的值。
*/
}
- 管程加锁规则:对一个锁的解锁
Happens-Hefore
于后续对这个锁的加锁。
public class HappensBefore4 {
private int x = 0;
private void setX(int x){
// 自动加锁
synchronized (this){
if (x < 12){
this.x = x;
}
}
// 自动解锁
}
}
- 线程start()规则。存在两个线程T1和线程T2。在线程T1中启动了线程T2。线程T2能够看到T1在启动T2之前所有的操作
public class HappensBefore5 {
/*
*存在两个线程T1和T2,假如是在T1中调用了T2的start方法,
*/
private int var = 77;
public void setVar(int var) {
this.var = var;
}
public int getVar() {
return var;
}
public static void main(String[] args) {
final HappensBefore5 hp5 = new HappensBefore5();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
// 在这里看到的值是22
System.out.println(hp5.getVar());
}
});
// 在这里对变量的值进行了修改
hp5.setVar(22);
t1.start();
}
/*在启动的线程中的所有的操作都是Happens-before。被调用start方法的中的所有的操作
*
*/
}
- 线程的join()规则。存在主线程T1和子线程T2,在T1中调用T2的join()方法。主线程可以看到子线程对共享变量的操作。
public class HappensBefore6 {
/*
*当存在两个线程分别为A B当A线程调用调用B线程的join方式等待结果的返回的时候。在方法返回之后,在子线程中对共享变量的操作在调用的线程中是
* 可见的。
*/
private int count = 0;
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
public static void main(String[] args) throws InterruptedException {
final HappensBefore6 hp6 = new HappensBefore6();
hp6.setCount(99);
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
// 模拟逻辑处理
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
hp6.setCount(22);
}
});
t1.start();
t1.join();
System.out.println(hp6.getCount());
}
}