1.线程安全的概念
线程安全是指在多线程环境下,程序能够正确地执行而不会出现数据不一致、资源竞争等问题。
简单来说,如果一个程序在运行时的结果符合我们的预期,那么这个程序就是线程安全的,反之,如果程序运行的结果和我们的预期不符,那么此时线程就是不安全的
示例引入:
启动两个线程,分别对count进行50000次的++操作,然后打印count的值
public class Main1 {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();Thread.sleep(1000);System.out.println(count);}
}
将上述代码多运行几次,我们会发现每一次的结果都是不一样的,且都是小于100000的
出现上述这种情况就叫做“线程不安全”
2.引发线程安全问题的原因
2.1原子性
什么叫原子性,原子就是不可再分
上述代码中的count++,表面上看着是一条代码,但是在CPU上对应3条CPU指令:
load:将count从内存读入到线程的寄存器中
add:对寄存器中count进行++操作
save:从寄存器中读取count
但是由于线程是随机调度的,就会出现可能t1线程执行到load就被调度去执行t2的save指令了,调度两个线程的指令是随机的
上面是t1和t2线程随机调度的一种情况
简单来说明一下:
第一步:t2线程先将count=1从内存读入到t2的寄存器中
第二步:t1线程先也将count=1从内存读入到自己的寄存器中
第三步:t2在寄存器中进行++操作,将count的值变为2
第四步:t1在寄存器中也对寄存器里的值进行++操作,将自己寄存器里的count变为2
第五步:内存从t1寄存器中读取count的值将自己原本的count值修改为2
第六步:内存从t2寄存器中读取count的值将现在的count=2也修改为2
除了上述这种情况,还有很多种,下面列举几种:
由于操作系统对线程的随机调度,多个线程修改同一个变量和代码不是原子性等造成了线程安全的问题
2.2内存可见性
内存可见性是指在多线程编程中,一个线程对共享内存的修改能够被其他线程及时的看到
下面来一个栗子引入:
public class Main2 {static int flag=0;public static void main(String[] args) {Thread t1=new Thread(()->{while(flag==0){};System.out.println("t1线程结束");});
t1.start();Scanner in=new Scanner(System.in);System.out.println("请输入falg的值:");flag=in.nextInt();}
}
当我们输入1时,修改了flag的值,可t1线程迟迟没有结束
显然,我们在main线程中修改flag的值,但是在t1线程中没有读取到flag的值,这是因为编译器的优化,JVM对我们所写的代码进行了优化,JVM会在保持我们代码逻辑不变的前提下,对代码进行调整,是程序的效率更高。但是尤其是在多线程的程序中,编译器的判断可能出现失误,就有可能导致优化前和优化后的逻辑出现细节上的偏差
分析一下上面所写代码出现的问题:
在执行上述代码的过程中,JVM感知到每次执行t1线程中的while循环flag的值一直没有发生改变,虽然我们手动修改flag的值也就几秒,但是while循环的执行可能已经执行几千万次了,所以JVM就将读取内存的操作改为读取寄存器,当我们修改flag的时候,t1线程寄存器中的值并没有改变,而内存中的flag值被修改为了1,此时t1线程仍然读取的是t1线程寄存器里的flag,导致了我们修改flag的值,t1线程没有结束的情况
2.3指令重排
指令重排是一种编译器和处理器为了优化程序性能而采取的手段。
在不改变单线程程序语义的前提下,编译器和处理器可能会对指令的执行顺序进行重新排列。例如,在单线程环境下,语句A和语句B不管先执行哪一个都不影响最终结果,那么就有可能发生指令重排。
然而在多线程环境下,指令重排可能会导致线程安全问题。比如有线程1和线程2同时访问共享变量,线程1中的操作顺序是先写共享变量a,再写共享变量b,但是由于指令重排,实际执行顺序可能是先写b再写a。如果线程2在某个时刻读取这两个变量,就可能会读取到不符合预期的中间状态的值。
为了避免这种情况,在多线程编程中可以使用一些机制,如volatile关键字(在Java等语言中),它可以确保对变量的操作顺序不会被随意重排,保证一定的“内存可见性”,即一个线程对变量的修改能及时被其他线程看到。还有像锁机制,在锁保护的代码块内,指令重排也会受到限制,以保证代码逻辑的正确性。
3.线程安全的解决方案
3.1加锁
锁的概念:
在多线程编程中,锁(Lock)是一种用于控制多个线程对共享资源访问的机制。
当多个线程同时访问共享数据时,可能会出现数据不一致等问题。比如,一个线程在读取一个变量时,另一个线程可能正在修改它。锁就像是一个房间的钥匙,同一时间只有一个线程能“拿到钥匙”访问被锁保护的资源。
对于上面这段概念,用我的理解来说就是,当一个线程去上厕所,这个线程就得把厕所门锁着,防止其他线程进入厕所,跟自己抢占这个厕所的资源。
也就是必须等线程执行完,释放了锁,其他线程才能使用
加锁大多时候是解决线程原子性的问题
下面我们分别对两个线程的count++操作进行加锁
public class Main3 {static int count=0;public static void main(String[] args) throws InterruptedException {Object lock=new Object();Thread t1=new Thread(()->{for (int i = 0; i <50000 ; i++) {synchronized(lock) {count++;}}});Thread t2=new Thread(()->{for (int i = 0; i <50000 ; i++) {synchronized (lock){count++;}}});t1.start();t2.start();Thread.sleep(1000);System.out.println(count);}
}
为了保证线程安全,我们使用了下面这个操作
锁对象:对于JAVA中的锁对象几乎可以是任意的引用类型,我们常用Object类的示例作为锁对象,要使两个加锁的代码分开执行,那么他们就必须使用同一把锁
加锁:当线程执行到 ‘ { ’时,那么从此刻开始运行的代码就会一直执行,其他线程进入阻塞等待状态
解锁:当线程中的代码执行到 ‘ } ’时,解锁后,其他线程才能被操作系统调度执行
对于上述两个线程,我们针对的是同一个对象加锁才会产生互斥效果(一个线程加上锁,另一个线程就得阻塞等待,等到第一个线程释放锁,才有机会执行);如果不是同一个锁对象,就不会产生互斥效果,线程安全就不会的到保证
解决线程安全问题,不是写了synchronized就可以而是要正确的使用锁
(1)synchronized{}代码块要合适
(2)synchronized()指定的锁对象也得合适
加锁也可以写成下面这种形式了
Thread t1=new Thread(()->{synchronized(lock) {for (int i = 0; i <50000 ; i++) {count++;}}});Thread t2=new Thread(()->{synchronized (lock){for (int i = 0; i <50000 ; i++) {count++;}}});
我们对每个线程里面的for循环进行加锁,只有当执行完t1线程的for循环才能执行t2线程for循环,这样的加锁其实两个线程差不多就相当于串行执行了
synchronized除了对一小段代码加锁,还可以对方法进行加锁,对于普通的方法,加锁的锁对象是this,对于静态方法加锁,锁对象是类对象
synchronized public int add(int a,int b){return a+b;}static synchronized void print(){System.out.println("hello word");}
可重入:
在JAVA中,可重入是锁的一个重要性质,当多个锁嵌套时,就会出现死锁的现象,可重入就可解决这种问题
在上面这段代码中 ,只有当第一个synchronized将锁释放,第二个synchronized才能获取到锁,,第一个synchronized没有释放锁,第二个也没获取到锁,就会出现死锁的情况,也就会造成线程阻塞的情况
为了解决上述情况,java就引入了可重入的概念
真正加锁和真正解锁都是在最外层执行的
站在JVM的角度,看到多个}需要执行,JVM是如何知道哪个}是真正解锁的那个?
这里面引入了一个计数器,当遇到{时,计数器++,当遇到}时,计数器--,当计数器--为0的时候就是真正解锁的时候
自己实现一个可重入锁也是上面那个原理
关于死锁:
第一种情况:
对于其他编程语言来说,一个线程一把锁,连续加锁两次就会造成死锁
而对于JAVA来说,引入了可重入锁,向上述这种情况就不会造成死锁
当某一个线程针对一个锁,加锁成功后,后续该线程再次针对这个锁进行加锁,不会触发阻塞,而是直接往下执行,因为当前这把锁就是被这个线程所持有,但是如果是其他线程尝试使用这个锁对象进行加锁就会正常阻塞
第二种情况:
两个线程两把锁,每个线程获取到一把锁之后,尝试获取对方的锁
public class Main5 {public static void main(String[] args) {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){System.out.println("t1线程两把锁都获取到了");}}});Thread t2=new Thread(()->{synchronized (lock2){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (lock1){System.out.println("t2线程两把锁都获取到了");}}});t1.start();t2.start();}
}
每个线程加上sleep的作用是保证每个线程都能够获取到第一把锁
当他们在获取到第一把锁之后想要去获取到另一个线程的锁时,由于各个线程都不能将自己手里锁进行释放,从而不能获取到对方的锁,这时就会造成死锁
第三种情况:
哲学家就餐问题
大家可参考这篇博客
哲学家进餐问题的三种解决方法(C++11)_哲学家进餐问题三种解决方案-CSDN博客
如何避免代码中出现死锁
构成死锁的四个必要条件:
1.锁时互斥的(锁的基本性质):一个线程拿到锁之后,另一个线程再次尝试获取锁,必须要阻塞等待
2.锁是不可抢占的(所得基本特性):线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待(不可剥夺),而不是线程2直接抢过来
产生死锁的必要条件有以下四个:
3. 请求和保持:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。例如,进程A已经占用了资源R1,它又请求资源R2,而R2被进程B占用,这时进程A就会等待,且不会释放R1。
4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。比如有进程P1、P2和P3,P1等待P2占用的资源,P2等待P3占用的资源,P3等待P1占用的资源,这种情况就形成了循环等待,很容易导致死锁。
对于上述的死锁的4种必要条件,对于我们来说,比较好破坏的就是请求和保存还有循环等待这两个条件
对于请求和保持,我们可以把嵌套的锁改成并列的锁
对于循环等待,我们可以对加锁的顺序做出约定(银行家算法)一文搞懂操作系统中银行家算法-CSDN博客
3.2volatile
这个关键字主要是解决线程安全中的内存可见性问题
上述我们谈到,内存可见性是由于编译器优化出现的bug,是将重内存读数据的操作变为读寄存器的操作,由于内存数据修改,线程没有读取到造成的
当我们对修改的变量加上这个这个关键字修饰,就会时变量在一个线程修改,在另一个线程能够立即读取到
我们对falg变量加上volatile修饰,当输入非0值时,就会结束t1线程的循环,使t1线程结束
public class Main6 {static volatile int flag=0;public static void main(String[] args) {Thread t1=new Thread(()->{while(flag==0){}System.out.println("t1线程结束");});Thread t2=new Thread(()->{Scanner in=new Scanner(System.in);System.out.println("请修改falg的值:");flag=in.nextInt();});t1.start();t2.start();}
}