线程安全问题是指在多线程编程中,多个线程同时访问共享资源时可能会出现的问题。由于多个线程同时运行,可能会导致数据竞争、死锁、活锁等问题,从而影响程序的正确性和性能。
一般情况可以这样认为:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
目录
出现线程安全问题的原因
我们现在有这样一段代码:
public class Solution6 {
public static int count;
//addcount方法:给共享变量count++
public static void addcount(){
Solution6.count++;
}
public static void main(String[] args) throws InterruptedException {
//线程1: 调用10000次addcount方法
Thread thread = new Thread(() -> {
for(int i = 0;i <10000;i++){
Solution6.addcount();
}
});
//线程2:调用10000次addcount方法
Thread thread1 = new Thread(() -> {
for(int i = 0;i <10000;i++){
Solution6.addcount();
}
});
//俩个线程先后开始运行
thread1.start();
thread.start();
//main线程休眠2000millis,确保thread 和 thread1线程执行完
Thread.sleep(2000);
//输出最终的count值
System.out.println(Solution6.count);
}
}
俩个线程同时开始对变量count执行++操作,分别进行10000次,按照我们的预想,最后count的值应该为20000。但是事实上我们发现最后count为小于20000且结果不固定的数。
这就是我们的线程安全问题。
产生线程安全问题的原因主要基于以下几个方面:
1.原子性
原子操作是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败,不会出现部分执行的情况。
在上述代码中,我们对静态成员变量count执行++操作,虽然从代码上看,++只是一个运算符,但实际上,在操作系统中,我们要执行++操作分为3步:
1:将数据从内存中拿到cpu中来
2:cpu对数据进行修改
3:将数据放回内存中
在单线程中,这并不会对结果产生影响,因为我们的操作都是线性进行的。
但是在多线程中,线程并发执行,即:在a线程对这个数据进行操作的过程中,b线程也可能拿到这个数据进行操作。
比如在示例中,线程thread和线程thread1同时对count进行10000次+1的操作,我们本意是让count加2万次,但是由于++操作不是原子的,即:有可能在thread拿到一个数据到cpu执行+1操作但还没有返回数据到内存中时thread1线程拿到count变量执行++操作:
类似的,俩个线程的1万次++操作可能以任意方式组合,这个过程不可控制(线程抢占式执行),所以我们示例中的代码的每一次运行结果都不一样。
2.可见性
可见性指: 一个线程对共享变量值的修改,能够及时地被其他线程看到.
java虚拟机中规定了Java内存模型:
1:线程之间的共享变量存在 主内存(内存)
2:每一个线程都有自己的 "工作内存"(寄存器)
3:当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
4:当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
寄存器贵,但是速度快;内存便宜但是速度慢很多。为了提高效率,此时系统就可能做出一些优化:将本来应该读取内存的操作优化成了读取寄存器,这时如果数据在内存中被其它线程修改,优化后的线程是看不到的,就可能会产生线程安全问题。
例如:
public class Demo7 {
public static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
while(flag){
}
System.out.println("线程1执行结束");
});
thread1.start();
Thread thread2 = new Thread(() -> {
flag = false;
System.out.println("线程2执行结束");
});
Thread.sleep(2000);
thread2.start();
}
}
在上述代码中,我们设置一个静态成员变量flag,并在thread1中以flag为条件执行一个内容为空的循环,在thread1执行一段时间后,通过线程2对flag进行修改。
在我们预期中,当线程2在将flag修改后线程1会跳出循环结束进程。
但实际上线程1没有跳出循环,也没有结束进程。
原因就是之前提到的:系统对线程1操作产生了优化。 thread1在thread2执行前已经执行了大量循环,由于循环的内容为空,则每次循环都是从内存中取到flag的值,加载到寄存器,然后进行读取。在这些大量循环中,flag的值从没发生过变化,每次的结果都一样。所以系统做了一个决定:不去内存了,直接在寄存器中取到flag的值,这就造成了thread1看不到thread2在内存中对flag的修改。
当在thread1循环里加上语句:
Thread.sleep(200);
结果:
thread1看到了thread2对flag的修改并跳出循环结束进程。原因:while循环的执行次数减少,优化后对性能和效率的提升变小,也就没有必要进行优化了
volatile关键字
使用volatile关键字修饰的变量,在读取和修改时都会强制刷新到内存中,从而保证了内存可见性。当一个线程修改了volatile变量的值时,其他线程会立即看到这个变化。
3.指令重排
指令重排是指为了提高程序的执行效率,编译器和处理器可能会对指令进行重新排序,使得程序在执行时的指令顺序与源代码中的指令顺序不同。
例如:对于操作:1:到图书馆借书
2:去宿舍睡觉
3: 到图书馆写作业
可以优化成:1:到图书馆借书并写作业
2:回宿舍睡觉
编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但 是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代 码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
4.修改共享资源
在多线程编程中,共享资源是指多个线程可以同时访问和修改的数据、对象或者其他资源。比如在前面的例子中,静态成员变量count ,静态成员方法addcount,以及在可见性部分示例代码的标志位 flag都属于共享资源。
5.线程的抢占式执行机制
线程的抢占式执行使得我们无法揣摩多个线程并发的执行顺序,进而由于原子性,可见性等原因就有可能产生线程安全问题。
synchronized
synchronized 是 Java 中用于实现线程同步的关键字,它的底层原理是基于对象的锁机制实现的。当一个线程进入 synchronized 代码块时,会尝试获取锁对象的锁,如果锁已经被其他线程获取,则当前线程会进入阻塞状态,直到锁被释放为止。当一个线程获得锁对象的锁时,其他线程需要等待该线程释放锁对象的锁后才能获取该锁。
在 Java 中,每个对象都有一个与之关联的锁,称为内置锁或监视器锁。当一个线程访问一个对象时,它必须先获得该对象的内置锁,才能执行该对象的方法或代码块。如果该对象已经被其他线程持有了内置锁,则当前线程需要等待,直到内置锁被释放为止。
synchronized 的实现机制就是基于这种内置锁机制实现的。当一个方法或代码块被 synchronized 修饰时,Java 会自动使用该方法所属对象的内置锁来实现同步。
需要注意的是:
当synchronized修饰 代码块 和 实例方法时,内置锁是与对象实例相关联的,而不是与类相关联。每个对象实例都有自己的内置锁,因此不同的实例之间是相互独立的。这意味着当一个线程持有一个实例对象的内置锁时,并不会影响其他线程对其他实例对象的访问。
当synchronized修饰 静态方法时,会对整个类对象进行加锁,这意味着当一个线程进入该方法时,其他线程需要等待该线程执行完该方法才能获取该类对象的锁并执行该方法。这也就是为什么synchronized关键字修饰的静态方法会对程序性能产生影响的原因。
synchronized特性
1:互斥性
指同一时刻只有一个线程可以持有锁,其他线程需要等待该线程释放锁后才能获取锁并执行相应的代码。
阻塞等待:
当一个锁被一个线程占有之后,其它锁就不能持有了,这时候就会进行阻塞等待,一直等到之前的线程解锁之后, 由操作系统唤醒一个新的 线程, 再来获取到这个锁.
需要注意的点:
*上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这 也就是操作系统线程调度的一部分工作.
*假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能 获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
2:可重入性、
线程的可重入性是指一个线程在持有锁的情况下,能够再次获取同一个锁而不会被阻塞。也就是说,线程在持有锁的情况下,可以多次进入被锁保护的代码块,而不会被阻塞。
这时候就产生了一个问题,什么时候释放锁呢?
锁内设置了一个计数器:
每个锁都与一个计数器相关联,当一个线程获得这个锁时,计数器的值会加1。当线程退出这个锁保护的代码块时,计数器的值会减1。只有当计数器的值为0时,锁才会被完全释放。
synchronized的使用
1:修饰代码块
public void method() {
synchronized (obj) {
// 代码块
}
}
使用实例对象obj作为锁对象
2:修饰静态方法
synchronized public static void method() {
// 方法体
}
类对象作为锁对象
3:修饰实例方法
synchronized public void method() {
// 方法体
}
调用这个方法的实例对象作为锁对象
死锁问题
什么是死锁?
1:两个或多个线程在执行过程中,因为互相持有对方需要的锁而相互等待的一种情况,导致所有线程都无法继续执行下去,程序陷入无限等待的状态。(哲学家就餐问题)
2:对于一个不可重入锁,即不具备可重入性,如果我们进行嵌套调用同一个不可重入锁,就会产生死锁(不常见)
public class Solution8 {
public static void main(String[] args) {
Object o1 = new Object();
Object o2 = new Object();
Thread thread1 = new Thread(() -> {
synchronized (o1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (o2){
System.out.println("线程1");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (o2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (o1){
System.out.println("线程2");
}
}
});
thread1.start();
thread2.start();
}
}
在上述代码中,我们先让thread1和thread2分别持有锁o1和o2,然后再让thread1和thread2在锁o1和锁o2中嵌套分别去获取锁o2和锁o1。我们发现这个时候就有一个矛盾:
thread1要想释放锁o1就得去获取锁o2,而锁o2此时被thread2占用,要让thread2去释放锁o2,就得让thread2获取锁o1,俩个线程互相持有对方需要的锁,导致所有线程都无法继续执行下去,程序陷入无限等待的状态。
比如:我把车钥匙忘到家里了,回家取车钥匙需要家里的钥匙,家里钥匙又被我锁车里了
产生死锁的必要条件
1:互斥条件:一个锁被一个线程持有时,另一个线程要想获得这个锁就要进行阻塞等待
2:不可抢占:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺。
3:请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
4:循环等待条件:若干个线程之间形成一种头尾相接的循环等待资源的关系。
解决方法:
1:尽量避免嵌套使用(针对必要条件3)
2:规定加锁的顺序,避免产生循环等待的情况(针对必要条件4)
对锁进行编号,如果要获取锁必须按照编号从小到大获取锁,这样就能避免产生循环等待的情况。
3:尽量缩小同步代码块的范围,减少持有锁的时间
4:使用定时锁、可重入锁等高级别锁机制