Java线程安全问题
1.1 线程安全的概念
线程安全:如何给出一个关于线程安全的概念是十分困难的,但是我们可以这样来理解,即在多线程模式下、相同代码运行得出的结果一定是一致无误且符合预期的,并与在单线程运行结果下一致,我们则认为这个程序是线程安全的。
1.2 演示线程安全问题
下面我们来演示一个经典的会引发线程安全的问题:
public class ThreadDemo01 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count变量的值是:" + count);
}
}
注:这里的join()
方法是确保线程t1、t2都执行完毕才会在主线程中打印变量count的值
从这段代码中我们可以我们定义了一个静态成员变量count初始化为0,我们希望线程t1执行10000次count++
,同理线程t2也执行10000次count++
,我们预估最后count变量的值应该是20000,可是结果真如我们所预料的这样吗?
控制台输出结果为14048,事实上每次运行的最终结果还不一样!这就非常的苦恼了,我们接下来就会分析引发线程安全问题的原因。
1.3 线程安全问题原因
1.3.1 抢占性调度(随机执行)
可以说导致线程安全的罪魁祸首就是操作系统中抢占式调度这种随机执行策略。倘若每一个线程执行的顺序都事先被规定好那么就不会引发线程安全问题,但是随机执行就会带来很多变数,进而引发线程安全问题。
1.3.2 多个线程修改共享数据
引发线程安全的其中一个条件就是必须存在多线程运行环境,试想如果只有一个线程那么与我们之前所编写的单线程环境是一致的,结果也不会出现不一致的情况。引发线程安全的另外一个原因就是存在多个线程想要访问一块共享空间的数据并涉及修改操作,就可能引发线程安全问题。以我们刚才的演示为例,其中类静态成员变量count
由于匿名内部类可以访问外部类成员的机制,可以被线程t1、t2同时访问,并且由于涉及count++
等修改操作,则可能引发线程问题。
1.3.3 修改操作非原子性
原子性:先来谈一谈原子性的概念,原子性是指无法再分的最小单位,在此处就是计算机执行代码以指令作为单位,由CPU进行取指令、解析指令、执行指令进行操作。
需要注意的是一条Java语句包含多个指令,以上述演示的count++
为例,就包含以下三个指令
- load:把内存中的count数据读取到寄存器中
- add:将寄存器中的数据进行+1操作再写回到寄存器中
- save:将寄存器中的数据写回到内存
此时可以明确的指出我们的示例代码正是非原子性操作,问题就出在count++
上,那么可能引起的效果就如下图所示:
一开始有线程t1持有CPU并执行指令load
、add
完成了从内存中读取count值为0并在寄存器中完成加法操作,在将结果写会内存之前就被CPU调度走了,线程t2从内存中读取count值仍旧为0!!!此时完整执行count++
三条指令将结果1写回内存。此时t1又重新被CPU调用继续执行,再将原来寄存器中的值1写回内存。这里我们就可以发现问题所在,原先预先的结果2由于抢占式调度策略和非原子性就导致了数据的不一致性,进而引发线程安全问题。
1.3.4 内存可见性
涉及到编译器优化问题,我们会在之后进行介绍
1.3.5 指令重排序
涉及到编译器优化问题,我们会在之后进行介绍
1.4 线程安全问题的解决—引入synchronized
我们先来看一下如何利用synchronized
解决上述案例的线程安全问题,然后再来着重介绍synchronized
的使用方式以及注意点。
public class ThreadDemo02 {
private static int count = 0;
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (obj) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (obj) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count变量的值是:" + count);
}
}
synchronized关键字就是Java中提供的监视器锁(Monitor lock)我们接下来就会详细介绍有关synchronized的使用方式以及特性
1.5 synchronized介绍
1.5.1 synchronized的特性
1.5.1.1 互斥性
synchronized能够起到线程互斥的效果,某个线程执行到synchronized代码块时,其他线程如果也执行到同一个对象synchronized就会阻塞等待
- 进入synchronized代码块中,相当于加锁
- 退出synchronized代码块当中,相当于解锁
- 进入由synchronized修饰的方法当中,相当于加锁
- 退出由synchronized修饰的方法当中,相当于解锁
如何理解“加锁解锁中的阻塞等待”:
针对于同一把锁,如果一个线程已经获取这个锁对象,操作系统会维护一个阻塞等待队列,如果别的线程想要获取同一把锁,就需要进入阻塞队列进行等待,直到持有锁的线程执行完毕释放锁,操作系统会根据线程调度模块选择一个线程获取锁对象。
- 上一个持有锁的线程解锁后,并不是下一个线程立马就可以执行,这期间需要经过操作系统模块经过线程调度策略选择一个线程并进行“唤醒操作”。
- 假如线程A获取了锁对象,B、C线程都进入阻塞等待队列,但是尽管B先于C进入阻塞等待队列,但是并不意味着B之后会优先获得锁对象,B、C线程仍旧需要进行竞争,不遵循“先来后到”原则。
1.5.1.2 可重入性
Java中的synchronized“加锁解锁”机制是可重入的,来看下面这个代码:
public class ThreadDemo03 {
private static Object obj = new Object();
public static void main(String[] args) {
Thread t = new Thread(() -> {
synchronized (obj) {
synchronized (obj) {
System.out.println("hhhh");
}
}
});
t.start();
}
}
如果按照上述阻塞等待机制来理解,我们或许会认为当进入第一个synchronized代码块时会获取锁对象,此时指定到第二个synchronized代码块,需要进入阻塞等待队列,因此这个代码不会输出任何结果进入死锁状态!实则不然,因为synchronized是可重入机制的,因此多个synchronized不会导致死锁现象的发生!
Java可重入锁实现:(线程持有者+计数器机制)
下面我们来分析Java是如何利用判断线程持有者和计数器实现的可重入锁
- 初始化计数器变量为0、线程持有者
- 进入第一个synchronized代码块内部,线程持有者更改为线程t
- 进入第二个synchronized代码块内部,比较当前线程与线程持有者,发现一致则计数器+1
- 退出第二个synchronized代码块内部,计数器值-1
- 退出第二个synchronized代码块内部,此时计数器为0,线程持有者释放锁
1.5.2 synchronized使用示例
-
synchronized修饰普通方法:锁对象为当前类的实例(this)
public class ThreadDemo04 { public synchronized void method () { } }
-
synchronized修饰静态方法:锁对象为当前类的类对象(Class)
public class ThreadDemo04 { public synchronized static void method() { } }
-
synchronized代码块:锁对象需手动指定
public class ThreadDemo04 { private Object obj = new Object(); public void method() { synchronized (obj) { } } }
注意:我们重点需要理解,synchronized锁的是什么对象,只有不同的线程想要获取同一个锁对象才会出现锁冲突竞争。
1.6 内存可见性(volatile关键字)
代码示例:
public class ThreadDemo05 {
static class Inner {
public int count = 0;
}
public static void main(String[] args) {
Inner inner = new Inner();
Thread t1 = new Thread(() -> {
while (inner.count == 0) {
}
System.out.println("循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数:");
inner.count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
这段代码的预期效果如下:
- 线程t1中判断内部类inner属性count的值是否为0,如果为0则保持死循环,否则退出循环并打印循环结束
- 线程t2中让用户输入一个数,将此值赋值给内部类变量count
- 预期结果就是t2线程中用户输入非0值,t1线程退出循环并打印循环结束终止
但是实际运行效果如下:
没错,t1线程依旧没有结束,这就是大名鼎鼎的内存可见性问题!在上面的描述中解释过内存可见性涉及编译器优化:其实Java编译器会做一件事,会在不影响代码逻辑的前提下对代码进行优化调整,但是这种策略在多线程编程模式下就会带来内存可见性问题:
- 我们虽然同时启动线程t1、t2,但是t2线程中由于存在输入等长时间的IO操作,因此实际上在我们输入完之后线程t1已经执行相当多次while循环
- 但是所有的while循环判断结果都是count值为0,while循环体也不涉及修改等操作,并且获取count的值需要经过访存操作,这对于CPU调度是非常耗时的,于是编译器优化方式就是通过寄存器或者缓存将内存中的count值拷贝了一份
- 但是若干秒后t2线程用户输入整数后改变了内存中count的值
- 此时t1线程仍旧会从三级缓存或者寄存器中读取count的值此时值依旧为0,所以t1线程中仍旧处于死循环。
volatile关键字的作用:
- 解决内存可见性问题(让每次读取指定变量值都从内存中读取)
- 解决指令重排序问题(后续再解释)
解决代码:
import java.util.Scanner;
public class ThreadDemo05 {
static class Inner {
public volatile int count = 0;
}
public static void main(String[] args) {
Inner inner = new Inner();
Thread t1 = new Thread(() -> {
while (inner.count == 0) {
}
System.out.println("循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数:");
inner.count = scanner.nextInt();
});
t1.start();
t2.start();
}
}