概述
线程安全问题有很多,本文章只介绍一些比较常见的有关线程安全的问题及解决方法。
目录
什么是线程安全
导致线程不安全的原因
1.抢占式执行(根本原因)
2.代码结构
3.原子性
4.内存可见性
5.指令重排序
解决线程安全的一些方法
synchronized
synchronized特性
synchronized的使用方法
volatile
Wait() + notify() 方法
使用注意事项
wait要和synchronized搭配使用
notify要和synchronized搭配使用
执行wait和notify的顺序
多个线程wait时,notify随机唤醒,notifyAll全部唤醒
wait与sleep的对比
相同点
不同点
什么是线程安全
在多线程的情况下,对于代码多次执行后所出现的结果和我们预期的结果一致,那么我们就可以说这是线程安全的,反之就是线程不安全。
导致线程不安全的原因
1.抢占式执行(根本原因)
在多线程的情况下,操作系统对于每个线程(没有任何限制)的执行顺序是没有限制的。哪个线程先执行,哪个线程后执行,是不确定的,预期结果是未知的。
这个问题目前我们无法做出改变,大多数操作系统都是抢占式执行,随机调度的。
2.代码结构
代码结构如果不好的话,比如在多线程的情况下,对于同一变量,如果是都只是读取这个变量,那么就没有问题;如果有的读取变量,有的修改变量;全部线程都修改同一个变量,那么这两种情况都可能会出现线程安全问题。
针对这种情况,我们可以调整代码结构。
3.原子性
在多线程的情况下比如我们平时调用一个方法,这个方法内部有很多条代码组成,如果这个方法不是原子性的,那么就很有可能出现线程安全问题。那么对于一条代码来说,比如i++这样的,是否可以看成是原子的?这里对于操作,本质上其实有三步:1.先把这个值读到(Load)到CPU当中的寄存器或者Cache中;2.在把这个值进行+1操作;3.最后在返回到内存当中去。
对于这些非原子的方法或者操作,我们可以加锁,使其变成原子的(看起来是)本篇本章将会使用synchronized这个锁来举例。实际上还是多条指令按顺序执行的,不过java.util.concurrent.atomic这里面的操作就是真正的原子性的,本质上就是一条指令,这种CAS锁在后续文章中会做介绍。
4.内存可见性
对于多线程代码,执行顺序很复杂,编译器在编译阶段会做出一些优化,比如对于有些看起来在一个线程里不会改变的变量,它可能就会直接只读一次,对于后续的变化也不管了,但是这个变量很可能会在另外一个线程中被修改,导致出现不可预知的结果。
对于这种我们知道是易变的量,但是编译器却不认为会变的变量,我们使用volatile这个关键字来修饰。这样只要一使用改变量,就会重新读到寄存器或者Cache中,速度虽然变慢了,但是准确性提高了。
5.指令重排序
不管是单线程还是多线程,都会出现指令重排序的情况。在保证逻辑不变的情况下,有可能是编译器,JVM或者CPU调整代码执行顺序或者指令执行顺序,使得程序效率提高。但是这种调整在单线程的情况下没有任何问题,在多线程中就可能会出现问题。
解决线程安全的一些方法
这里的方法针对上述的原子性和内存可见性提出了一些解决方法。
synchronized
这个关键字就是为了让方法等变得具有原子性。
首先来看一下具有线程安全的代码(没有原子性)
class Counter {
public int count;
public void add (){
count++;
}
}
public class ThreadDemo14 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t14a = new Thread(()-> {
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
Thread t14b = new Thread(()-> {
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
t14a.start();
t14b.start();
t14a.join();
t14b.join();
System.out.println(counter.count);
}
}
两个线程对同一个变量都自增5000次,那么预期结果应该是10000才对,现在我们来看一下结果。
运行多次就会发现每次的结果都不一致,都达不到10000。
通过以下这个图我们可以得出结论。
对于上述的情况就需要用到synchronized关键字来让这个操作变的具有原子性。
只需要对add方法进行如下操作
public void add (){
synchronized (this) {
count++;
}
}
或者
// synchronized只要是在返回值前即可,所以在public后也可以
synchronized public void add (){
synchronized (this) {
count++;
}
}
此时运行结果就恒为10000了
synchronized特性
1. 互斥
比如上述代码的a线程使用了add方法后,此时b线程也想使用改方法,那么b线程只能阻塞等待a线程使用完该方法,b线程才可以使用这个方法。这种不能同时使用一个资源的关系就叫互斥。
进入synchronized所修饰的代码块,相当于加锁
离开synchronized所修饰的代码块,相当于解锁
synchronized用的锁时Java对象里的,底层是操作系统里的互斥锁。
甲和乙两个人(两个线程),此时有一个厕所(相当于被synchronize修饰的代码块),若是甲先进去,他就把厕所门关上了(加锁)。乙如果只想上这个厕所,那么他就只能在外面等待甲上完,离开厕所(解锁);不过如果乙找到了其他空闲厕所,那么他就可以去其他厕所(阻塞的线程也可以做其他事情)。
2. 可重入
a线程对于x对象加锁,此时还没有解锁,a线程又对x对象加锁。如果没有出现死锁的情况,那么这就是可重入的,否则就是不可重入。synchronized就是可重入的。
死锁:这里的死锁就是a线程对于x对象加锁后,又继续加锁,那么根据互斥的原则,a线程应该阻塞才对。但是a线程是加锁的,只能等a线程释放锁才可以有结束阻塞的操作,这样不就僵住了吗。死锁的情况有很多,将在后面的文章中详细介绍。
synchronized的使用方法
修饰静态方法
synchronized public static void method() {
// synchronized只要在返回值前即可
}
class Test{
public static int a = 10;
public int b = 20;
synchronized public static void method1(String s) throws InterruptedException {
System.out.println("此时是" + s + "在使用这个类");
// 为了观察出来synchronized只是对Test类中的静态方法加锁
// 对于其他的成员没有影响,此处对于这个方法休眠3000ms
Thread.sleep(3000);
}
}
public class ThreadDemo15 {
public static void main(String[] args) throws InterruptedException {
Thread t15a = new Thread(() -> {
try {
Test.method1("t15a线程");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t15b = new Thread(() -> {
try {
System.out.println(Test.a);
Test test = new Test();
System.out.println(test.b);
Test.method1("t15b线程");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t15a.start();
t15b.start();
t15a.join();
t15b.join();
}
}
修饰实例方法
synchronized public void method() {
// synchronized只要在返回值前即可
}
修饰代码块
public void method3(String s)
{
synchronized (this) {
// 谁调用这个代码块, 这个this就是那个对象
}
}
public void method3(String s)
{
synchronized (Test.Class) {
// 这里就是Test这个类对象被synchronized修饰
}
}
不管synchronized修饰什么,只有多个线程对相对这个synchronized修饰的对象加锁,这是才会出现锁竞争。其他的就进行阻塞。
若是多个线程对不同的对象进行加锁,这时是不会出现锁竞争的。
volatile
一:该关键字是为了让变量的性质在编译器看来是易变的,为了解决内存可见性的问题。
二:使用该关键字,还可以禁止指令重排序。
先来看一段问题性代码。
import java.util.Scanner;
class Test16{
public int flag = 0;
}
public class ThreadDemo16 {
public static void main(String[] args) throws InterruptedException {
Test16 test16 = new Test16();
Thread t16a = new Thread(()-> {
while (test16.flag == 0) {
// 这里如果flag不等于0了,循环就会结束
}
});
Thread t16b = new Thread(()-> {
// 为了让t16a停止工作
System.out.println("请输入一个非零整数:");
Scanner scanner = new Scanner(System.in);
int sc = scanner.nextInt();
test16.flag = sc;
});
t16a.start();
t16b.start();
}
}
上述代码中没有让t16a这个线程停下来,也就是说flag变量一直是0,我们看似是改变了flag的值,但是又好像没有改变。
这是因为编译器/JVM优化了上述过程。在t16a线程中,flag = 0的值先是从内从中加载到CPU中,然后编译器就认为这个值后续应该不会在改变了,于是它就只读这一次 0 的值,后续循环所使用的还是CPU中的 0 ,而不再重新从内从中读,单线程确实如此。但是在多线程的情况下,情况比较复杂,编译器无法做出正确的决定。所以需要我们手动指定,让编译器只要一使用该值,就要重新在内存中去读。
手动指定就是给所有可能会改变的变量 用volatile关键字修饰,这样编译器认为这就是个易变的量,每次就重新读。
这样虽然降低了速度,但是提升了准确性。
// volatile只要在类型前即可
// volatile只能修饰变量,其他的都不能修饰
volatile public int flag = 0;
再次运行上述代码。
Wait() + notify() 方法
注意这两个方法与上面的线程安全问题没有关系,这里介绍这两个方法仅仅是因为这两个方法和synchronized方法有着密切的关系。
方法 | 说明 |
void wait() | 哪个线程调用该方法后,没有唤醒条件就会一直阻塞下去。此时线程状态是WAITING |
void wait(Long timeout) | 超过这个时候还没有被唤醒,就会从WAITING变成RANNABLE |
void wait(Long timeout, int nanos) | 时间精确到了纳秒 |
void notify() | 唤醒与之相对应的线程 |
void notifyAll() | 唤醒所有正在阻塞的线程 |
这几个方法都是Object类下的方法。因为Object是所有类的父类,因此所有的类都可以使用这几个方法。
使用注意事项
wait要和synchronized搭配使用
notify要和synchronized搭配使用
如果不和synchronized一起使用,将会抛出一下异常。
出现这个异常的原因是,wait的工作原理
1.wait先释放锁(这样的话其他线程就可以获取synchronized的当前对象的锁)
2.再让该线程进行阻塞等待
3.达到条件后,再尝试获取锁,再进行接下来的操作
public class ThreadDemo17 {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
Thread t17 = new Thread(()->{
synchronized (o) {
try {
System.out.println("wait前");
o.wait();
System.out.println("wait后");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t17.start();
// 确保先执行wait(),所以让主线程休眠100ms再去唤醒
Thread.sleep(100);
synchronized (o) {
System.out.println("notify前");
o.notify();
System.out.println("notify后");
}
}
}
正是因为它俩作用的是同一个对象,才可以起到效果。
执行wait和notify的顺序
如果没有wait就notify,这样也没有什么问题。但是一定要保证之后的notify会唤醒后面执行的wait。因此保险起见,最好确保先执行wait,然后执行notify。
把上面代码中main线程中的sleep方法注释掉就会唤不醒t17线程。
多个线程wait时,notify随机唤醒,notifyAll全部唤醒
notify会随机唤醒一个线程,notify会唤醒全部线程,然后这些线程再竞争锁。
wait与sleep的对比
实际上,二者完全没有可比性。wait是用于线程间的通信,而sleep只是让线程阻塞规定的时间。
相同点
都可以让线程阻塞
不同点
唤醒不同:wait是正常的唤醒,要么是notify,要么是达到规定时间(没有任何异常,正常的逻辑 业务)
sleep被唤醒,是被InterruptedException这个异常唤醒的(出了问题)
使用方法:wait必须搭配synchronized使用
sleep不需要
方法类型:wait是Object类里面的实例方法
sleep是Thread类的静态方法
有什么问题评论区指出,希望可以帮到你。
最后
以上就是端庄蛋挞为你收集整理的【JavaEE】线程安全 + wait和notify方法什么是线程安全导致线程不安全的原因解决线程安全的一些方法Wait() + notify() 方法的全部内容,希望文章能够帮你解决【JavaEE】线程安全 + wait和notify方法什么是线程安全导致线程不安全的原因解决线程安全的一些方法Wait() + notify() 方法所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复