小贴士:
本节题目所述的主题其实非常的庞大,如果要细讲起来,一篇博客远远不够,本篇博客只会每个方面的内容做一个简要描述,详细的内容在后续同专栏博客中都会涉及到的,如果有需要可以一步到本专栏的其他博客。
正文开始:
一、什么线程安全问题?
示例演示:
这里用一个直观的代码来展示一个经典的线程安全问题:
public class demo1 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
//分别通过两个线程对count++
t1.start();
t2.start();
t1.join();//join方法的作用是主线程运行到这了,先等待t1线程结束,在回来运行主线程。
t2.join();//t2线程同理
System.out.println("count="+count);//输出最后的count值
}
}
按照正常的逻辑,count值因该是10000,可是:
很奇怪😕
这实际上就是出现了线程安全问题。
是什么原因导致这些问题的呢?
我们接下来将会详细讲解。
二、造成线程安全问题的主要原因
主要原因:
1、操作系统对线程的调度,在程序运行角度看是“随机的”(抢占式执行)
2、代码结构,即多个线程同时修改同一个变量。
3、修改变量这个操作不是原子性*的。
4、指令重排列(Instruction Reordering,后序章节详细讲解)
5、内存可见性(Memory Visibility,后续章节详细讲解)
6、线程饿死(Thread Starvation,后续章节详细讲解)
7、死锁(Deadlock,后序章节详细讲解)
注:
原子性*的意思是对于一个操作,结果只能是做了和没做两种状态,不能出现第三种状态。
刚才示例出现线程安全问题的原因就是1、2、3点导致的:
在代码中我们知道t1和t2两个线程时并发执行的,并且都对count变量进行++操作。
而在CPU的视角看,count++操作要分成三步(不是原子的):
1)load:把count对应的内存数据写入寄存器。
2)add:逻辑运算单元对数据进行++操作。
3)save:把新的值重新写入count变量的内存。
t1和t2两个线程并发执行,都在不断按照上面这三步指令执行,在系统“随机”调度的过程中就很可能出现这样一种情况:
某一时刻,t1和t2同时load了count的内存数据,并且两个线程load的count值时一样的,然后他们分别对count++,最后写入内存(save)。会过头来我们发现,在这两个线程都运行完一次后,count只进行了一次++操作!
在深入问大家一个问题,程序中的count有没有可能小于5000呢?
答案是可能的。
可能的情况举例:
t1和t2都只能对count++5000次,倘若又这样一个情况,t1刚开始被调度,读取到的count值是0,然后由于抢占式执行,t2开始被调度并且被多次连续调度,导致最后t2线程执行了4999次,之后t1又开始被调度把count=0写回原来的内存(形成了覆盖),然后t2又被调度了把count=0读取到逻辑运算单元,这是又由于抢占式执行,t2停止运作,t1开始被连续调度执行了5000,count被修改成了5000。
现在只剩下t2还没有执行了,t2把count=0(在t2的逻辑运算单元上)++,对count进行覆写,count竟然还变成了1!
三、线程安全问题的解决办法
1、给线程加锁
像刚才的示例,我们可以通过设置多个变量的方式进行解决:
public class demo1 {
public static int count = 0;
public static int count1=0;
public static int count2=0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count1++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count2++;
}
});
//分别通过两个线程对count++
t1.start();
t2.start();
t1.join();
t2.join();
count=count1+count2;
System.out.println("count="+count);
}
}
不过这不是JAVA解决线程安全问题的主流方式,了解即可。
在JAVA中,主流解决线程安全问题的方式是对线程进行“加锁”的操作。
什么是锁?
刚才讲解线程安全问题的原因时,我们提到了原子性、修改同一个变量这两个关键字,这里所说的锁实际上就是把一些非原子性的程序“锁”起来,让它变成原子性的,这样线程安全问题就被解决了。
比如刚才的t1和t2线程,都对count进行++操作。但是由于系统的“随机”调度,两个线程的load、add、save操作是相互穿插进行的,数据的修改很可能会出错。
而现在把[load、add、save]这个非原子性的++操作进行“上锁”,保证要么++操作成功,要么什么都没有操作,既++操作变成了原子性的。
通过锁的这种操作,两个线程的【load、add、save】🔒就不可能穿插执行了,因为必须完成【】🔒内的操作,才能去执行另一个线程的任务。
这样线程安全问题就得到了解决。
synchronized关键字(加锁的工具)
synchronized基本用法:
Java提供了 synchronized 关键字 (监视器锁-monitor lock)来完成加锁操作。
接下来通过synchronized关键字,解决上面的线程安全问题:
public class demo1 {
public static int count = 0;
public static Object object1 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (object1) {//括号内填写一个实例对象,任何类性的对象都是可以的!!
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {//括号内填写一个实例对象,任何类性的对象都是可以的!!
synchronized (object1) {//代码块中填写,需要原子化的程序。
count++;
}
}
});
//分别通过两个线程对count++
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count=" + count);
}
}
此时运行结果count=10000
synchronized除了上述写法,还可以通过修饰静态方法或者成员方法的方式,实现加锁:
public class Threads {
static int count = 0;
//实现加锁
private synchronized static void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread thread1=new Thread(()->{
for (int i = 0; i <5000 ; i++) {
add();
}
});
Thread thread2=new Thread(()->{
for (int i = 0; i <5000 ; i++) {
add();
}
});
thread1.start();
thread2.start();
Thread.sleep(1000);//这样可以极大增大 先让thread1和thread2两个线程先执行完,在打印count的概率
System.out.println(count);
}
}
成员方法是同理的,这里就不做过多演示了。
synchronized的一些基本特性:
1)互斥(Mutual Exclusion)
进入synchronized代码块内,相当于上锁。
退出synchronized代码块,相当于 解锁。
对于同一个对象,如果一个线程上了锁,那么其他线程必须等待这个线程解锁,才能运行:
锁外其他的线程就处在BLOCK的等待状态。
图中的同一个对象是什么意思?
在刚才的代码演示中,t1和t2两个线程的synchronized括号里,填写的都是同一个对象。
如果两个线程填写不同的对象,跟没加锁没有区别,最后的count大概率也不可能等于一万。
也就是说,对于同一个对象加锁,锁对于一个线程来说才是有效的,或者说是存在的。
比如,我们对上面的代码进行简单的修改,t1线程和t2线程两个锁对象不同:
public class demo1 {
public static int count = 0;
public static Object object1 = new Object();
public static Object object2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (object1) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (object2) {
count++;
}
}
});
//分别通过两个线程对count++
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count=" + count);
}
}
其中一个运行结果:
2)可重入(Reentrant)
如果重复对同一个线程进行这种加锁会怎么样:
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (object1) {//第一次锁了
synchronized(object1){//第二次我在锁?
count++;
}
}
}
});
我们来慢慢分析:
第一次加锁:
本来t1线程可以安全的上侧所的,但是他还不放心,于是synchronized代码块里,又上了一次锁:
所以根据上面的逻辑,重复对一个线程针对同一个对象加锁是会出现锁被“焊死”的情况的(也就是死锁)
这种重复锁导致的死锁,只会出现在C++\Python等其他编程语言中,Java不会,因为synchronized关键字会对这个情况进行判断,不会对相同对象的相同线程进行重复上锁。
具体代码举例:
//先清楚标志位,然后抛出异常
public class Threads {
static int count = 0;
//实现加锁
private synchronized static void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
synchronized (Threads.class){
synchronized (Threads.class){
System.out.println("在第二个锁的内部");
}
}
});
Thread.sleep(1000);
thread.start();
thread.join();
System.out.println("thread线程结束");
}
}
以上代码在逻辑上是错误的,因为对同一个对象同一个线程重复上锁了,但是程序并没有卡主:
原因就是synchronized关键字会自动识别是重复上锁,如果有只会上锁一次。
那么如果没有synchronized的这个特性,程序会怎么样呢?
如图:
程序将会永远的停留在第22行和23行之间,在第22行第一次上锁后,程序需要等待第一次锁的解锁,才能在23行位置进行在次上锁,这样就形成了一个逻辑闭环,循环依赖,永远无法退出!(C++\Python等这些语言就有可能出现这种状况)
额外知识补充:
synchronized关键字是JVM提供的功能,synchronized底层实现就是依靠JVM中C++代码调用操作系统的API来实现的。而这些操作系统的API又是通过CPU上特殊的指令来实现上锁、解锁的。
2、volatile关键字
这个关键字是专门解决内存可见性问题的,这里不做过多解释,同专栏后续博客有详细讲解。
3、wait和notify方法
这两个方法是Object类自带的,用于解决线程饿死问题,这里不做过多解释,同专栏后续博客有详细讲解。
完