Java多线程详解(2)
一、多线程带来的风险 - 线程安全问题
1.1、线程不安全的实例
public class Test03 {public static long count=0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for(long i=0;i<500000000;i++){count++;}});Thread t2 = new Thread(()->{for(long i=0;i<500000000;i++){count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println("count="+count);}
}
在上述代码中,我们分别创建了两个线程,分别对count进行了累加500000000次的操作,如果不出意外,那么最终count的打印结果应该是1000000000,但结果果真如此吗?以下是运行结果:
通过运行结果,我们可以发现:最终值与预期值不符合。由此我们引出线程安全问题:程序运行得到的结果与预期值不一致,是错误的结果,而我们程序的逻辑是正确的,这个现象所表现的问题称为线程安全问题
1.2、线程不安全的原因
1.2.1、线程调度是随机的
线程是抢占执行的(执行顺序是随机的)—>人为解决不了,完全是CPU自己调度,并且和CPU核数有关
1.2.2、修改共享数据
多个线程修改了同一个变量会出现线程安全问题。多个线程修改不同的变量,不会出现线程安全问题。一个线程修改一个变量,也不会出现线程安全问题
1.2.3、指令执行过程中不能保证原子性
原子性是指一个操作或一组操作要么全部成功执行,要么全部不执行,确保在执行过程中不会被中断或出现中间状态。
1.2.4、线程修改共享变量时内存不可见性
1、Java线程首先从主内存读取变量的值到自己的工作内存
2、每个线程都有自己的工作内存,且线程工作内存之间是隔离的
3、线程在自己的工作内存中把值修改完成之后再把修改后的值写回主内存
1.2.5、指令重排序
什么是指令重排序?举个例子:
假设有一段代码顺序执行
1、去前台拿U盘
2、去教室写20分钟作业
3、去前台取快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按1->3->2的方式执行,也是没问题的,可以少跑一次前台。这种叫做指令重排序
编译器对于指令重排序的前提是"保持逻辑不发生变化".这一点在单线程环境下比较容易判断,但是在多线程环境下就没那么容易了,多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价
二、synchronized 关键字
2.1、synchronized特性
2.1.1、互斥
synchronized 会起到互斥效果,某个线程执行到某个对象的synchronized 中时,其他线程如果也执行到同一个对象就会阻塞等待
进入synchronized修饰的代码块,相当于加锁
退出synchronized修饰的代码块,相当于解锁
举个通俗的例子,就相当于一个人在上厕所,他进去之后会把门锁上防止其他人进来,而他上完厕所后会把锁打开,这时其他人才能进去,在那个人出来前其他人只能在外面等待。
注意:假设有A,B,C三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C都在阻塞队列中排队等待.但是当A释放锁之后,虽然B⽐C先来的,但是B不⼀定就能获取到锁,⽽是和C重新竞争,并不遵守先来后到的规则
2.1.2、可重入
synchronized 同步块对同⼀条线程来说是可重入的,不会出现自己把自己锁死的问题
自己把自己锁死:一个线程还没有释放锁,又再一次被加锁,第一次加锁成功,第二次锁已经被占用,阻塞等待,直到第一次的锁被释放,才能获取到第二个锁.但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作.这时候就会 死锁
但是Java中的锁是可重入锁,不会存在上述问题
2.2、synchronized的使用
2.2.1、修饰代码块:明确指定锁哪个对象
(1)锁任意对象
public class Test04 {private Object locker=new Object();public void func(){synchronized (locker){}}
}
(2)锁当前对象
public class Test04 {public void func(){synchronized (this){}}
}
2.2.2、直接修饰普通方法
public class Test04 {public synchronized void func(){}
}
2.2.3、修饰静态方法
public class Test04 {public synchronized static void func(){}
}
2.3、关于synchronized
(1)实现了原子性(通过加锁)
(2)通过串行执行实现了内存可见性
(3)没有禁止指令重排序
2.4、锁对象
锁对象本身就是一个简单对象,任何对象都可以作为锁对象,锁对象中记录了获取到锁的线程信息
2.5、锁竞争
(1)只有一个线程要获取锁,直接获取,没有锁竞争
(2)线程A和线程B共同抢一把锁,谁先拿到锁,就先执行谁的逻辑,另一个线程就要阻塞等待,等到持有锁的线程把锁释放之后,再去抢锁资源,存在锁竞争
(3)线程A和线程B抢的不是同一把锁,他们之间没有竞争关系,分别去拿自己的锁,不存在锁竞争
三、volatile关键字
3.1、保证了内存可见性
JMMJava内存模型规定:当一个线程要修改变量时,不能直接修改主内存的值,而是要把变量复制到自己的工作内存中,修改之后再刷新回主内存
所以当一个线程修改了共享变量的值,其它线程并不知晓,但当加了volatile之后情况就不一样了
代码在写入volatile修饰的变量的时候,
• 改变线程工作内存中volatile变量副本的值
• 将改变后的副本的值从工作内存刷新到主内存
代码在读取volatile修饰的变量的时候,
• 从主内存中读取volatile变量的最新值到线程的工作内存中
• 从工作内存中读取volatile变量的副本
代码示例:
创建两个线程t1和t2,其中t1包含一个循环,这个循环以flag==0为循环条件,t2则是输入一个数,并把这个数赋值给flag。当输入一个非零的值时,t1线程结束
import java.util.Scanner;public class Dome_Thread08 {static int flag=0;public static void main(String[] args) {Thread t1 = new Thread(()->{System.out.println(Thread.currentThread().getName()+"线程启动...");while(flag==0){//一直循环}System.out.println(Thread.currentThread().getName()+"线程退出...");},"t1");t1.start();Thread t2 = new Thread(()->{System.out.println(Thread.currentThread().getName()+"线程启动...");Scanner in=new Scanner(System.in);System.out.println("请输入一个非零的整数:");flag=in.nextInt();System.out.println(Thread.currentThread().getName()+"线程退出...");},"t2");t2.start();}
}
其运行结果如下:
我们通过观察可以知道,在没有给flag加volatile的情况下,t1并没有意识到flag已经更改,从而导致线程没有退出
以下是加了volatile的情况:
import java.util.Scanner;public class Dome_Thread08 {static volatile int flag=0;public static void main(String[] args) {Thread t1 = new Thread(()->{System.out.println(Thread.currentThread().getName()+"线程启动...");while(flag==0){//一直循环}System.out.println(Thread.currentThread().getName()+"线程退出...");},"t1");t1.start();Thread t2 = new Thread(()->{System.out.println(Thread.currentThread().getName()+"线程启动...");Scanner in=new Scanner(System.in);System.out.println("请输入一个非零的整数:");flag=in.nextInt();System.out.println(Thread.currentThread().getName()+"线程退出...");},"t2");t2.start();}
}
其运行结果如下:
3.2、禁止指令重排序
禁止指令重排序的原理:volatile通过在指令序列中插入内存屏障来实现。
内存屏障是一种CPU指令,其作用:
(1)保证特定操作的顺序性:内存屏障前后的指令不能进行重排序。
(2)保证变量的内存可见性:强制刷新CPU缓存,使所有线程都能读取到最新的数据。
3.3、不保证原子性
代码示例:
public class Test05 {public static volatile long count=0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for(long i=0;i<1000000;i++){count++;}});Thread t2 = new Thread(()->{for(long i=0;i<1000000;i++){count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println("count= "+count);}
}
其运行结果为:
通过运行结果我们可以发现,其结果仍然不符合预期,说明volatile不能保证原子性
四、wait()、notify()和notifyAll()
我们知道,线程是抢占式执行的,所以正常情况下我们无法预知线程执行的先后顺序,这个时候就需要用到上述的三个方法。
4.1、wait()
(1)wait()的作用
1、使当前执行代码的线程进入等待(把线程放入等待队列中)
2、释放当前的锁
3、满足一定条件时被唤醒,重新尝试获取锁
wait()要搭配synchronized使用,脱离synchronized使用wait()会直接抛异常
(2)wait()等待结束的条件
1、其它线程调用该对象的notify()方法
2、wait()的等待时间超时(wait(long timeout)->timeout是指等待时间)
3、其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException 异常
(3)代码示例
public class Test04 {public static void main(String[] args) throws InterruptedException {Object locker=new Object();synchronized (locker){System.out.println("开始等待...");locker.wait();System.out.println("等待结束...");}}
}
其运行结果如下:
可以看到,因为wait()让线程进入了等待,所以后面的“等待结束”并不能打印出来,这时就需要有个方法来把它唤醒。
4.2、notify()
(1)notify()的作用
唤醒等待的线程
(2)使用注意事项
1、方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁
2、如果有多个线程等待,则由线程调度器随机挑选出一个呈wait状态的线程。(并没有"先来后到"一说)
3、在执行notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
(3)代码示例
创建两个线程t1和t2,t1调用wait()方法,t2调用notify()方法,然后启动两个线程,观察运行结果
public class Test06 {public static void main(String[] args) {Object locker=new Object();Thread t1 = new Thread(()->{synchronized (locker){System.out.println("t1线程等待开始...");try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1线程等待结束...");}});Thread t2 = new Thread(()->{synchronized (locker){System.out.println("t2开始唤醒t1...");locker.notify();System.out.println("唤醒结束...");}});t1.start();t2.start();}
}
其运行结果如下:
4.3、notifyAll()
(1)notifyAll()的作用
一次唤醒所有等待线程
(2)代码示例
public class Test06 {public static void main(String[] args) throws InterruptedException {Object locker=new Object();Thread t1 = new Thread(()->{synchronized (locker){System.out.println("t1线程等待开始...");try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1线程等待结束...");}});Thread t2 = new Thread(()->{synchronized (locker){System.out.println("t2线程等待开始...");try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t2线程等待结束...");}});Thread t3 = new Thread(()->{synchronized (locker){System.out.println("t3开始唤醒操作...");locker.notifyAll();System.out.println("唤醒结束...");}});t1.start();t2.start();Thread.sleep(1000);t3.start();}
}
这里加sleep()的原因是防止t3在t1和t2之前就开始运行,导致结果不符预期
其运行结果如下:
注意:虽然是同时唤醒2个线程,但是这2个线程需要竞争锁.所以并不是同时执行,而仍然是有先有后的执行
4.4、使用小结
1、wait和notify/notifyAll必须搭配synchronized使用
2、wait和notify/notifyAll使用的锁对象必须是同一个
3、notify/notifyAll执行多少次都没关系(即使没有线程在wait)
未完待续