在之前的文章中,已经介绍了关于线程的基础知识。我的主页: 🍆🍆🍆爱吃南瓜的北瓜
欢迎各位大佬来到我的主页进行指点
一同进步!!!
🍇一、观察如下代码
我们创建两个线程t1和t2,对静态变量count执行++操作各50000次。
我们的预期结果是100000。但是当两个线程分别执行++操作时最后的结果是否为100000呢?
看这样一段代码
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
运行三次 结果如下
上述结果和预期差距很大
我们将线程写作串行执行
运行结果如下
第一次的代码运行没有达到我们的预期结果,是因为两个线程同时操作一个共享变量,涉及到线程安全问题。
🍇二、线程安全概念
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象时线程安全的。
🍇三、线程不安全的原因
- 线程在系统中是随机调度的,是抢占式执行的
这是系统决定的
- 多个线程对同一个变量进行修改
如果没有抢占式执行,一个线程接着一个线程逐个完成自己的任务,那么也就不会担心这样的情况
- 线程针对变量的操作,不是“原子”操作
原子操作:是指不可再细分的操作
而文章开头举的例子中n++这一操作,在系统中其实被细分成三步:
第一步:把内存中count的值加载到CPU寄存器中
第二步:把寄存器中的值+1,还是继续保存在寄存器中
第三步:把寄存器的值写回到内存中的count。
- 内存的可见性问题,引起的线程不安全
- 指令重排序引起的线程不安全
如下是对文章开头的案例做出的解释
🍇四、解决线程不安全
线程的随机调度这是系统决定的。无法干预
通过修改代码结构,来避免多个线程对同一个变量的修改。
解决原因三,我们就引入锁的概念
1.锁synchronizatied
锁本质上是操作系统提供的功能,由JVM封装提供api供我们使用。
锁后面带上()
()里面写的就是“锁对象”
锁对象的用途,有且只有一个,那就是用来区分,两个线程是否针对同一个对象加锁。
如果是,就会出现锁竞争/锁冲突/互斥,就会引起阻塞等待。
如果不是,就不会出现锁竞争,也就不会阻塞等待。
至于这个对象是什么类型,没有关系。
在Java中,synchronized进入{ 就自动上锁,出 } 就自动解锁
免去了加锁解锁
锁的特性
1) 互斥
synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时,
其他线程如果也执⾏到同⼀个对象 synchronized 就会阻塞等待.
• 进⼊ synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁
2)可重⼊
synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题;
可重入实例
public static int count = 0;
public static void main(String[] args) {
Object locker = new Object();
Thread thread = new Thread(()->{
synchronized (locker){
synchronized(locker){
for (int i = 0; i < 50000; i++) {
count++;
}
}
}
});
}
在可重⼊锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
- 如果某个线程加锁的时候, 发现锁已经被⼈占用, 但是恰好占⽤的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
synchronized
修饰普通方法,相当于对this加锁
修饰静态方法,相当于对类加锁
死锁
如下就是一段典型的死锁代码
public class main2 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread(()->{
synchronized (lock1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2){
for (int i = 0; i < 5000; i++) {
count++;
}
}
}
});
Thread t2 = new Thread(()->{
synchronized (lock2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock1){
for (int i = 0; i < 5000; i++) {
count++;
}
}
}
});
t2.start();
t1.start();
t1.join();
t2.join();
System.out.println(count);
}
}
线程t1拿到了锁lock1,并上锁,进入sleep等待
此时线程t2拿到了CPU的执行权,拿到了锁lock2,并上锁,进入sleep等待
t1又拿到CPU的执行权,此时想要拿到锁lock2,但是此时锁lock2被线程t2占用着,
此时线程t1拿不到锁lock2,不能 进行之后的操作,想要跳出这个代码块,
但是想要开启锁lock1,就必须拿到锁lock2,此时就进入了死锁的状态。
产生死锁的必要条件
-
锁具有互斥特性
这是锁的基本特点,一个线程拿到锁之后,其他线程就只能阻塞等待。 -
锁不可抢占(不可被剥夺)
一个线程拿到锁之后,除非它自己释放锁,否则别人抢不走 -
请求和保持
一个线程拿到一把锁之后,不释放这个锁的前提下,在尝试获取其他锁 -
循环等待
多个线程获取锁的过程中,出现了循环等待,A等待B,B又等待A。
以上四点缺一不可,缺少一个都构成不了死锁
如何避免死锁
第一点和第二点是synchronized的基本特性,这是更改不了的。
所以只能从第三点第四点开始入手
第三点的解决方案是
在书写代码时,尽量避免锁的循环嵌套,可以有效避免死锁的发生
第四点的解决方案是
约定好加锁的书顺序让所有的线程都按照规定的顺序来加锁
2.内存可见性问题‘
观察如下代码
public class main3 {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
while (count == 0){
//循环体中什么都没有
}
System.out.println("线程t1结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入整数count");
count = scanner.nextInt();
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
运行如下
我们预期看到的应该是输入1后,程序结束。
但结果与预期并不相同,这是为什么呢?
问题主要出在循环体这里
我们可以看到循环体内什么都没有
站在指令的角度来刨析问题
1) load操作,从内存中将count的值读取到CPU寄存器中。
2)cmp操作 (比较 同时跳转)
条件成立,继续顺序执行
条件不成立,跳转到另一个地址来执行。
由于当前循环体是空的,循环体旋转速度很快
短时间内出现大量的load和cmp操作反复执行的效果
load执行消耗的时间会比cmp多很多(相差几个数量级)
这时JVM发现,load速度非常慢且每次load执行的结果都是一样的(t2未更改时)此时,JVM就会把load操作给优化掉,当然这是JVM的一个优化的bug
那么如何解决呢?
在循环内书写IO操作或阻塞操作(sleep),就会使旋转体的速度降低了。
while (count == 0){
System.out.println("hello t1");
}
IO操作使循环速度减低
如果循环操作中存在IO操作,就没有优化load操作的必要了
IO操作不能被优化!!!
这就是内存可见性问题’
一个线程针对一个变量进行读操作,另一个线程针对这个变量进行修改,此时读的线程,不一定能感知到这个变量被改了
解决方案
引入volatile关键字
这个关键字从字面意思上理解是 “易变的,不稳定的”,如果给变量加上这个关键字,仿佛在告诉 JVM/编译器,这个变量很不稳定,极有可能发生变化,从而不让编译器优化!
格式如下
volatile static int count = 0;
但是volatile不能保证原子性
实例如下
public class main4 {
public volatile 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++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
//第一次执行9979
//第二次执行9190
//第三次执行9715
}
把光标放置到count++这条语句上,可以发现如下报错。
对易变字段 ‘count’ 的非原子操作
对 count 这个易变字段执行了一个非原子操作,这可能会导致在多线程环境下数据的不一致或不可预测的行为。在实际编程中,如果你需要对某个字段进行复合操作(比如先读后写或先比较后更新),并且这个操作需要在多线程环境中是安全的,那么仅仅使用 volatile 是不够的,你还需要使用其他同步机制(如 synchronized 块或 java.util.concurrent.atomic 包中的原子类)来确保操作的原子性。
小结
使用synchronized可以保证原子性
使用volatile可以保证内存可见性
如果后面写代码的时候,既要考虑原子性,又要考虑内存可见性,直接把 synchronized 和 volatile 都加上即可。文章来源:https://www.toymoban.com/news/detail-846221.html
以上就是本文所有内容,如果对你有帮助的话,点赞收藏支持一下吧!💞💞💞文章来源地址https://www.toymoban.com/news/detail-846221.html
到了这里,关于【Java】线程安全问题的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!