一.线程安全问题
线程安全介绍
在java多线程编程中,线程安全是一至关重要的概念,当多个线程同时访问和修改共享数据时,如果没有正确的同步和协调机制,就可能会导致数据不一致、竞态条件和其他难以调试的问题。在 Java 中,确保线程安全是开发可靠并发应用程序的关键。
线程安全意味着多个线程可以同时访问和操作共享的数据,而不会导致意外的结果或错误。这需要仔细设计和实现数据结构、方法和代码块,以确保在并发环境下的正确性。
在本博客中,我们将深入探讨 Java 线程安全的各个方面。我们将了解什么是线程安全,为什么它如此重要,以及如何在 Java 中实现线程安全。
二.线程安全实例
class HestHeap{
public int num = 0;
public void increase(){
num++;
}
}
public class Test {
public static void main(String[] args) {
HestHeap hestHeap = new HestHeap();
Thread thread = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase();
}
});
Thread thread1 = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase();
}
});
thread.start();
thread1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(hestHeap.num);
}
}
第一个线程循环1000次去增加num的值,第二个线程也循环1000次去增加num的值,应该num的值增加2000,但是打印结果只增加了1799次,跟我们预期的值少了很多。这种在单线程下没有错误,在多线程下出现错误的方式叫线程安全问题。
该代码首先cpu要拿到当前num的值,然后在寄存器上对Num进行+1操作,继续在写回内存中。本质为3步,load(cpu上显示当前num的值),add(寄存器上进行+1操作),save(把Num的值写回内存)。
下图为没有线程安全问题理想状态的一次执行:
图二为出现线程安全问题:
线程安全并不只有一种,实际上有很多种情况,例如:
造成上面线程不安全的原因为,多个线程同时执行的时候,操作系统会抢占式的策略执行线程,虽然自增2次,但是由于2个线程并发执行,导致中间结果覆盖了。
三.Synchronized
为了解决操作系统抢占式的调度线程造成线程不安全的现象,java提供了synchronized关键字,对需要多个线程同时修改的变量进行加锁,从而解决掉这种抢占式的现象,达到线程安全。
比如上厕所时要关门,synchronized相当于门的一把锁,上锁后除非我结束出来其他人才能进来。根据这个原理,我们让上面代码线1的,load,add,save操作全运行结束再运行第二个线程的load,add,save 操作,这样就能避免掉中间结果被覆盖掉,也就是这正常状态。
lass HestHeap{
public int num = 0;
synchronized public void increase(){
num++;
}
}
public class Test {
public static void main(String[] args) {
HestHeap hestHeap = new HestHeap();
Thread thread = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase();
}
});
Thread thread1 = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase();
}
});
thread.start();
thread1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(hestHeap.num);
}
}
代码中的锁同一时刻只能有一个线程拿到锁,去使用这个变量,线程1调用这个加锁的方法,当运行结束才会解锁,线程2才能得到这把锁去调用这个加锁方法。同理线程2运行加锁方法时,线程1也只能休眠等待,等线程2运行完释放锁。
试想一下这样加锁把并发执行变成了串行执行这样还有意义吗?串行执行只是一小部分,大多代码还是并发执行,仍然要比纯粹的串行执行效率高。
一.synchronized以对象加锁
上述加锁代码其实本质是这样的
class HestHeap{
public int num = 0;
public void increase(){
synchronized (this) {
num++;
}
}
}
public class Test {
public static void main(String[] args) {
HestHeap hestHeap = new HestHeap();
Thread thread = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase();
}
});
Thread thread1 = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase();
}
});
thread.start();
thread1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(hestHeap.num);
}
}
synchronized加锁其实是给对象加锁的,接着继续探究,当2个线程对同一个对象加锁和不同对象加锁有什么不同。
class HestHeap{
public int num = 0;
public void increase(Object object){
synchronized (object) {
num++;
}
}
}
public class Test {
public static Object object = new Object();
public static void main(String[] args) {
HestHeap hestHeap = new HestHeap();
Thread thread = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase(object);
}
});
Thread thread1 = new Thread(()->{
for(int i=0;i<1000;i++){
hestHeap.increase(object);
}
});
thread.start();
thread1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(hestHeap.num);
}
}
给同一个object对象加锁发现结果和预期的一样,那给不同对象加锁呢?
class HestHeap{
public int num = 0;
public void increase(Object object){
synchronized (object) {
num++;
}
}
}
public class Test {
public static Object object = new Object();
public static Object object1 = new Object();
public static void main(String[] args) {
HestHeap hestHeap = new HestHeap();
Thread thread = new Thread(()->{
for(int i=0;i<10000;i++){
hestHeap.increase(object);
}
});
Thread thread1 = new Thread(()->{
for(int i=0;i<10000;i++){
hestHeap.increase(object1);
}
});
thread.start();
thread1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(hestHeap.num);
}
}
线程1给object加锁,线程2给object1加锁,给2个不同的对象加锁出现了错误。synchronized本质是一个对象锁,要想线程安全只能给同一个对象加锁,给不同对象加锁并不能保证线程安全。
二.锁竞争
当给同一个对象加锁,多线程调用锁里面的方法时,就会出现锁竞争。线程1拿到这把锁时,线程2休眠等待,等线程一加锁,方法运行结束解锁,后面的线程就会抢占这把锁,出现锁竞争。对不同对象加锁并不会出现阻塞等待,也就不会出现锁竞争。
三.内存可见性
在 Java 中,内存可见性是指多个线程对共享变量的访问和修改在其他线程中可见的特性。确保内存可见性是多线程编程中的一个重要方面,它涉及到线程之间的通信和协同工作。
public class Test2 {
public static int ret=1;
public static void main(String[] args) {
Thread thread = new Thread(()->{
while(ret==1){
if(ret!=1){
break;
}
System.out.println("hello");
}
});
thread.start();
System.out.println("请输入");
Scanner scanner = new Scanner(System.in);
ret = scanner.nextInt();
System.out.println(ret);
}
}
上述代码当我们输入不是1时,发现循环并未停止。发生了内存可见性问题。这是因为程序员是负责写代码的,当写好一个代码后编译器会进行优化,可能会认为你的代码不够好,代码运行的时候编译器可能对代码进行了修改,在保持原有的逻辑不变的情况下,提高代码的运行效率。大部分时候编译器的调整是好的,但是到多线程可能会出现差错。上面代码中多线程中的ret本质是2个操作,一个是读内存,一个是比较ret值是不是1。因为读内存操作速度非常慢,但是比较在寄存器中的值非常快,此时编译器发现这个代码逻辑中,代码要反复的快速执行同一个内存中的值,并且读出来每次一样,于是编译器就会把读操作优化了,只是第一次执行读,以后不需要读操作,直接寄存器中拿。当Main函数里面修改该变量的值时,编译器就不会继续读这个变量,此时就不会改这个变量的值,从而循环停不下来。要想停循环停止,让volatile修饰这个变量。
public class Test2 {
public volatile static int ret=1;
public static void main(String[] args) {
Thread thread = new Thread(()->{
while(ret==1){
if(ret!=1){
break;
}
System.out.println("hello");
}
});
thread.start();
System.out.println("请输入");
Scanner scanner = new Scanner(System.in);
ret = scanner.nextInt();
System.out.println(ret);
}
}
此时就不会发生内存可见性问题。
当线程中有sleep的时候即使不用volatile修饰也可能不会发生内存可见性问题。这是因为线程执行到sleep要休眠一段时间,编译器会感觉休眠的时候没有事情,会在去读这个变量从而避免内存可见性问题。
mport java.util.Scanner;
public class Test2 {
public static int ret=1;
public static void main(String[] args) {
Thread thread = new Thread(()->{
while(ret==1){
if(ret!=1){
break;
}
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("hello");
}
});
thread.start();
System.out.println("请输入");
Scanner scanner = new Scanner(System.in);
ret = scanner.nextInt();
System.out.println(ret);
}
}
四.wait和notify
在多线程编程中,线程间的协作和通信是至关重要的。当多个线程需要共享资源或协调工作时,它们需要一种方式来等待、通知和协调彼此的操作。Java 中的 wait 和 notify 方法就是为了实现这样的目的而设计的。
wait 方法允许一个线程暂时停止执行,进入等待状态,直到它被其他线程通过 notify 或 notifyAll 方法唤醒。这使得线程能够在等待特定条件满足时避免无效的循环或忙等待,从而提高了程序的效率和性能。
notify 方法用于唤醒正在等待的线程。当一个线程完成了某个关键操作或者改变了共享条件后,它可以通过调用 notify 方法来通知其他等待的线程继续执行。
public class Test2 {
public static int ret=1;
public static Object lock = new Object();
public static void main(String[] args) {
Thread thread = new Thread(()->{
System.out.println("start开始");
synchronized (lock){
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
System.out.println("main线程结束");
}
}
线程thread执行到wait停止了,并没有继续运行。因为wait会让程序进行休眠,当notify唤醒的时候才能继续执行后面的内容。
public class Test2 {
public static int ret=1;
public static Object lock = new Object();
public static void main(String[] args) {
Thread thread = new Thread(()->{
System.out.println("start开始");
synchronized (lock){
try {
lock.wait();
System.out.println("end结束");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
synchronized (lock) {
thread.start();
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
lock.notify();
System.out.println("main线程结束");
}
try {
thread.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
wait和notify和synchronized都是依赖对象的,都必须针对同一个对象,如果是不同对象wait和notify是不会停止等待的。wait和notify都是Object的方法。
wait在执行的时候会做三件事:
1.解锁 ,针对Object对象解锁
2.阻塞等待
3.当其他线程被唤醒后,就会尝试重新加锁,加锁成功,wait执行完毕。
所以wait和notify必须依赖锁对象,如果没有锁对象会报错。
五.线程饿死
wait和notify主要是用来里安排线程之间的执行顺序。最主要的场景是避免线程饿死。
假如1号蔡徐坤在上厕所,厕所门上了锁,其他人想要进去只能等1号蔡徐坤结束,解锁才能拿到这把锁进去上厕所。如果1号蔡徐坤上完厕所解开锁,又进去继续锁了厕所门,又过了几分钟出来,继续进去锁了厕所门,如此重复,都是蔡徐坤1号加锁解锁,这种就叫线程饿死。同一个线程加锁解锁,后面线程没有办法拿到这把锁。wait和notify能有效解决这个问题,wait解锁判断1号蔡徐坤有没有在厕所,如果没有就进行wait操作,其他蔡徐坤j就可以进去。
1.wait和notify注意事项:确保notufy和wait是同一个对象
2.wait和notift必须放到synchronized里面。
3. 如果notify的时候没有线程wait,那么啥作用没有。
wait和sleep区别:
sleep休眠是有明确时间,时间一到自动唤醒。也能提前,使用interrupt
wait是一个死等,没有唤醒一直等下去,也能被interrupt唤醒。
四.总结
线程安全问题根本原因:
1.多个线程之间的调度是随机的,操作系统抢占式的执行策略来调度线程.
2.多个线程同时修改同一变量。
3.进行的修改不是原子的。
4.内存可见性
5.指令重排性,引起的线程安全问题。