目录
5. wait 和 notify (协调多个线程之间的执行先后顺序)
一. 多线程简述
1. 随cpu的进入多核时代,为提高程序的执行速度,就要充分利用cpu的多核资源,也就是是实现多进程。但多进程消耗资源大,速度慢(创建,销毁,调度的开销都挺大,即多进程对“资源的分配/回收”消耗过大)。所以,多线程(轻量级进程)也就应运而生。多线程将“资源的分配/回收”省略掉,复用前有的资源,即共用。
二. 多线程注意事项
1.一个进程包含一个或多个线程(注:一个线程不能属于多个进程)
2.同一个进程的多个线程之间,共用同一份资源(主要指 内存 和 文件)
3.操作系统 实际调度(调度资源)是时,是以线程为单位调度。(每个线程在cpu上都是独立调度)
4.进程是操作系统分配资源的基本单位
5.设置线程数量时,因cpu核心数量有限,线程过多,不仅不能提高效率,反而浪费开销在线程的调度上,设置线程数量时应根据需求,合理设置。
6.多线程安全问题:(下面有具体介绍)
7.若某一个线程抛出异常,处理不善,很可能会影响它所属于的整个进程。(原因:多线程共用同一份资源。 对比:多进程因资源独立,则 不容易 触发这个问题)
三. 多线程代码简单实现
1.Java多线程核心操作的类(Thread)
2.五种创建多线的方式
继承 Thread 类,重写run方法
使用 Runnable接口,创建多线程
使用匿名内部类实现创建多线程
使用匿名内部类,实现Runnable方法
使用Lambda表达式 (最简单,推荐)
1.五种创建多线程的方法
1.1继承 Thread 类,重写run方法
package thread;
//继承 Thread 类,重写run方法
class MyThread extends Thread { //使线程成为独立的执行流
@Override
public void run() {
while(true) //若没有多线程,程序在此就陷入死循环,只有一个打印。
{ //有多线程,就会和下面的打印交替出现
System.out.println("hello world");
try {
Thread.sleep(1000); //休眠1s
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class ThreadDemo1 {
//Thread类在java.lang下,不用手动导入包
public static void main(String[] args) {
Thread t= new MyThread();
t.start(); //多线程的一个特殊方法,作用:创建一个新线程,新线程执行t.run
//主线程调用t.start()方法,创建出一个新线程负责run方法,并且当ruan执行完毕时,新线程自然销毁
// t.run() 若直接调运,则等价于单线程
while(true)
{
System.out.println("hello 123456");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
1.2.使用 Runnable接口,创建多线程
package thread;
//使用Runnable接口创建多线程
//Runnnable:描述一个需要执行的任务,run方法是实现任务的方式。
class MyRunnale implements Runnable{
@Override
public void run() {
System.out.println("hell word");
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
Runnable runnable =new MyRunnale();
//任务描述
Thread t=new Thread(runnable);
t.start();
//将任务交给一个新线程执行
}
}
//该方法实现了代码的解耦合,使线程与线程之间所需执行的任务分开
//方便与后续改动代码
1.3. 使用匿名内部类实现创建多线程
package thread;
//使用匿名内部类实现创建多线程
public class ThreadDemo3 {
public static void main(String[] args) {
Thread t = new Thread(){
public void run(){
System.out.println("hello word");
}
};
t.start(); //创建新线程并执行run方法
}
}
1. 4.使用匿名内部类,实现Runnable方法
package thread;
//使用匿名内部类,实现Runnable方法
public class ThreadDEmo4 {
public static void main(String[] args) {
Thread t= new Thread(new Runnable() {
@Override
public void run() {
System.out.println("hello word");
}
});
t.start();
}
}
1.5.使用Lambda表达式 (最简单,推荐)
package thread;
//使用Lambda表达式,最简单,推荐
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t= new Thread(()->{
System.out.println("hello word");
});
t.start();
}
}
四. Thread 类及常见方法(一部分介绍和使用)
构造方法 | 说明 |
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用Runnablle 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target , String name) | 使用Runnablle 对象创建线程对象,并命名 |
属性 | 获取方法 | 备注 |
ID | getif() | 获取线程的身份标识 |
名称 | getName() | 获取构造方法中对线程的命名(与上面对应) |
状态 | getSate() | 获取线程状态(java线程状态箱较操作系统更丰富,这里不详述) |
优先级 | getPriority() | 可获取,也可设置(设置效果影响不大,前文有描述) |
是否守护线程 | isDameon() | 守护线程(不管它是否结束)不会阻止进程结束,非守护线程(未结束)会阻止进程结束。我们手动创建的线程,默认为非守护线程 |
是否存活 | isAlive() | 首先在调用 start() 方法之后,系统才会在内核中创建一个 pcb ,这时 pcb 才代表一个真正的线程。所以,在调用之前, isAlive() 是f alse , 调用之后才是 true 。是判断线程是否真的存在。 其次,当线程中的 run() 执行结束,此时线程销毁,pcb随之销毁。这时,isAlive() 也是 false |
package thread;
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t= new Thread(()->{
System.out.println("hello word");
});
t.start();
t.getId(); //ID
t.getName(); //名称
t.getState(); //状态
t.getPriority(); //优先级
t.isDaemon(); //是否守护线程
t.isAlive(); //是否存活
t.isInterrupted(); //是否中断
}
}
1. 线程终止
注意!!!
中断一个线程,不是让线程立即停止,而是 ‘通知’ 线程应该停止,至于是否真的停止,取决于线程的代码如何实现的(三种情况:不中断,等一会中断,立即中断)
1.1 通过标志位中断
public class ThreadDemo {
private static boolean flage=true; //定义一个静态变量 flage 作为标志位
public static void main(String[] args) {
Thread t= new Thread(()->{
while(flage){ //标志位
System.out.println("hello word");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
flage = false; //主线程main 可随时通过修改 flage 的值来做到中断线程 t
//注意!!!这里之所以做到中断线程,完全是由于线程内部代码的设计
}
}
1.2调用 interrupt() 方法
public class ThreadDemo5 {
//此代码为 调用后,但不中断的情况。具体解释见下文
public static void main(String[] args) {
Thread t= new Thread(()->{
while(!Thread.currentThread().isInterrupted()){ //Thread.currentThread().isInterrupted() Thread自带的标志位
System.out.println("hello word"); //Thread.currentThread() Thread 的一个静态方法,作用是获取当前线程
try { //那个线程调用这个方法,它就代表那个线程,类似于 .this
Thread.sleep(1000); //isInterrupted() 若它为true 表示被终止
} catch (InterruptedException e) { //相反则表示未被终止。
throw new RuntimeException(e);
}
}
});
t.start();
t.interrupt(); //在主线程main中 t 线程调用interrupt()这个方法后,t 线程被终止
}
}
interrupt 还有一个功能,在上述代码中,如果t 线程在sleep中休眠中,此时调用interrupt(),会通过触发sleep内部的异常,从而提前唤醒线程。
注意!!! 如果运行上述代码,会发现 t 被中断了,但依旧会持续输出 hell word ?
解析:
当调用 interrupt() 方法后会做两件事。(1.将标志位的值 置为true。2.触发sleep的异常,唤醒线程。)
但是,当sleep的内部异常被触发后,线程被唤醒,但标志位却被sleep清除了,也就是标志位的值再次被置为 false。 所以就会while循环会继续执行。线程未中断 (这里,也就再次说明了,中断线程,只是通知它应该中断了,是否真的中断,取决于代码的设计)
此时,sleep清除标志位,就让线程何时中断可由程序员控制(三种情况,不中断,等一会中断,立即中断)。
2.程序员决定如何让中断
标志位被sleep清除,就让程序员对线程的中断有了可操作性,或者叫可选择性。从上文可知线程中断可分为(三种情况,不中断,等一会中断,立即中断),而sleep的清除标志位,让我们可以根据需求,选择对应的中断情况
1.1.不中断
上一个代码
1.2 立即中断
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t= new Thread(()->{
while(!Thread.currentThread().isInterrupted()){
System.out.println("hello word");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
break; //跳出循环。
}
}
});
t.start();
t.interrupt(); //在主线程main中 t 线程调用interrupt()这个方法后,t 线程被终止
}
1.3 稍后中断
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t= new Thread(()->{
while(!Thread.currentThread().isInterrupted()){
System.out.println("hello word");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
//在第一个sleep触发异常后,在添加一个sleep.就是等待一会,在执行break;
//在这个catch下,可以写任何代码
e.printStackTrace();
try {
Thread.sleep(500);
} catch (InterruptedException ex) {
e.printStackTrace();
}
break;
}
}
});
t.start();
t.interrupt(); //在主线程main中 t 线程调用interrupt()这个方法后,t 线程被终止
}
3. 等待一个线程 ( join 控制线程结束的顺序)
以下面代码为例,本身在调用 t.start 后,主线程main和 t 线程并发执行。但在调用 t.join后 会让 t 线程执行完毕,在执行主线程main。 这也叫阻塞(block)
Join方法的两种(无参数,有参数)
1. public void join(); 这个方法,会一直等待线程结束
2.public void join(long minllis); 这个方法,有一个最大等待时间的参数。到时间就不等
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t= new Thread(()->{
for(int i=0;i<10;i++)
{
System.out.println("t线程在执行"); //输出
}
});
t.start();
System.out.println("join 开始阻塞");
try {
t.join(); //阻塞
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t 执行完毕");
}
}
4.获取当前线程
方法:public static Thread currendThread();
说明:返回当前线程的引用对象(谁调用,返回谁的实例)
用法:类名.currendThread
5.休眠线程
方法:public static void sleep(long millis); (参数:/毫秒)
说明:本质上是让线程不参与调度,休眠时间到达之后,才参加调度
用法:类名.sleep(millis);
五.线程状态
1.观察线程的所有状态
1.状态是针对 调度 描述的,而线程是调度的基本单位。
2.java对线程状态的描述,进行了细化
(1).NEW:创建了Thread 对象,但是还未调用 start
(2).TERMINATED: 线程执行完(内核中pcb执行完毕),但是Thread对象还存在
(3).RUNNABLE: 可运行状态(a.正在cpu上运行 b.准备就绪,随时可以去cpu上运行)
(4).WAITING: 阻塞状态 <——
(5).TIMED_WAITING: 阻塞状态 | 都表示阻塞,但阻塞原因不同和
(6).BLOCKED: 阻塞状态 <——
3.方法:Thread.State.valuse()(线程状态是一个 枚举 类型)
六. 多线程安全问题
为什么会有多线程安全问题?万恶之源--->抢占式执行,带来的随机性
抢占式执行:(在操作系统调用多线程 时,会“抢占式执行”,具体那一个线程先调用,是不确定的,取决于操作系统调度器的具体实现策略。代码的执行顺序固定,结果固定。但线程的调度充满随机性了,那代码执行顺序随机了,结果也就从固定,变成多种可能性)
注:从代码角度来看,线程之间的调度是 “随机” 的,但这里的随机是:内核本身是非随机的,但因为干预因素太多,并且应用程序无法感知这一细节,所以造成 “随机”的假象。(多线程的执行顺序又是由内核实现的,无解。可以通过api进行一些 有限 干预)
1.什么情况(常见的)会出现线程安全问题
1.1【根本原因】抢占式执行,随机调度(这个原因,目前无能无力)
1.2 代码结构:避免多个线程修改同一个变量
1.3 修改操作的对象是非元原子性的(原子性:单个指令,无法拆分。)
1.4 内存可见性问题(一个线程读,一个线程修改,也可能会出现问题)
1.5 指令重排序(本质上是编译器在自动优化代码的时候,在逻辑上不变的前提下会对单个线程内 代码的执行顺序进行调整)
2. 如何解决线程安全问题
2.1 synchronized 的使用(加锁)
假如 线程1 和线程2 修改同一个 非原子性变量 ,且加锁(线程对 某一个对象加锁)对象为同一个,发生锁竞争。那么,当线程1 先加锁到这个对象(也可以理解为,先调用到这个对象),那么线程2 就会处于 阻塞状态,直到线程1 的加锁对象 执行完毕。
synchronized 的使用方法(明确加锁对象):
(1)修饰方法 (注意:加锁对象不是被修饰的方法,而是所属的对象)
a.修饰普通方法
b.修饰静态方法 (加锁对象是 类)
(2) 修饰代码块 (手动指定加锁对象)
synchronized 修饰方法
class Counter{
public int count=0;
public synchronized void add() // synchronized: 对 Counter这个对象 加锁
{ //注意!!!! 不是对方法加锁
count++; // ++操作,本质上分为三步(内存->寄存器; 寄存器+1; 寄存器->内存)
} // 所以,++操作为 非原子性!!!!
}
public class ThreadDemo {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{ //线程1: t1
for(int i=0;i<10000;i++)
{
counter.add();
}
});
Thread t2 = new Thread(()->{ //线程t2: t2
for(int j=0;j<10000;j++)
{
counter.add();
}
});
t1.start(); //创建t1线程,执行。
t2.start(); //创建t2线程,执行。
try {
t1.join(); //等待线程
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(counter.count);
}
}
synchronized 修饰方法
class Counter{
public int count=0;
public void add()
{
synchronized(this){ //synchronized 修饰代码块,this 这的对象可自定义。
count++; //进入代码块加锁,出代码块解锁
}
}
public class ThreadDemo {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{ //线程1: t1
for(int i=0;i<10000;i++)
{
counter.add();
}
});
Thread t2 = new Thread(()->{ //线程t2: t2
for(int j=0;j<10000;j++)
{
counter.add();
}
});
t1.start(); //创建t1线程,执行。
t2.start(); //创建t2线程,执行。
try {
t1.join(); //等待线程
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(counter.count);
}
}
2.2 加锁监视器 monitor lock
jvm 给synchronized 起的另外一个名字。加锁和解锁是两个操作,所以有可能出现某一个线程加锁后,忘记解锁,那么和其 发生锁冲突 的线程就一直处于阻塞状态,所以为避免出现这种状况,synchronized就基于代码块的形式,解决了这一情况
2.3 可重入
一个线程对同一个对象,连续进行两次加锁。如果没有问题,就叫可重入。发生错误,就叫不可重入
class Counter{
public int count=0;
public synchronized void add() //对 Counter 加锁,线程调用add,第一次加锁
{
synchronized(this){ //synchronized 修饰代码块,this 指向Counter对象。
count++; //进入代码块,第二次加锁
}
}
public class ThreadDemo {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{ //线程1: t1
for(int i=0;i<10000;i++)
{
counter.add();
}
});
t1.start(); //创建t1线程,执行。
try {
t1.join(); //等待线程
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(counter.count);
}
从被加锁的对象(this)角度来看,线程第一次调用加锁后。进入方法,遇到代码块,进行第二次加锁。此时 该对象认为自己被线程占用,第二此加锁,是否需要进入阻塞状态,等待?
此时,又是一个特殊的情况,两次调用同为一个线程,那么是否允许这样操作呢???
如果允许,就是可重入;不允许,就是不可重入,线程陷入 '死锁' 状态
结论 :
但在java语言中,不可避免出现上述写法。为了避免出现死锁的情况,java,对 synchronized 设定是可重入的
2.4 Java标准库中的线程安全类
多线程调用同一个集合类,也需要考虑到线程安全问题
- 内置synchronized 加锁的,相对来说安全(不是绝对)
Vector(不推荐使用)
HashTable(不推荐使用)
ConcurrentHasMap
StringBuffer
- 没有内置synchronized 加锁的,多线程使用时需多加注意!遇到线程安全问题,需要手动加锁
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
- 特殊的一个类:String 类
String 没有内置加锁,但是线程安全的。因为它是不可修改对象。多线程对String读取,没有线程安全问题
3.内存可见性问题
内存可见性问题:假设线程A对一个变量进行读操作。同时线程B对变量进行修改操作。此时,线程A读取到变量的值,不一定是线程修改后的值。
举例:
class MyCounter{
public int flage=0;
}
public class Test {
public static void main(String[] args) {
MyCounter my = new MyCounter();
Thread A =new Thread(()->{
while (my.flage==0){ //线程A 只进行了对flage的读操作
;
}
System.out.println("线程A结束运行");
});
Thread B =new Thread(()->{ //线程B 对flage进行修改
Scanner s =new Scanner(System.in);
System.out.println("请输入一个整数:");
my.flage=s.nextInt();
});
//代码预期: 线程A,B,并发执行,所以一旦线程B对flage进行修改为非0,线程A结束运行
A.start();
B.start();
}
}
代码预期功能:线程A,B,并发执行,所以一旦线程B对flage进行修改为非0,线程A结束运行,输出 “线程A结束运行”
实际效果:
输入5之后,程序并未停止运行。
分析:为什么程序未达到预期效果,出现BUG
1. 上述程序,从汇编来看,针对线程A的 while()循环, 分为两个操作
load: 将flage的值,从内存中读取到寄存器中,并进行判断
cmp: 将寄存器的值,与0比较。根据比较结果,判断程序下一步执行方向
2. 上述循环,执行速度非常非常快。一方面,在线程B真正修改 flage的值之前,循环已执行很多次。另一方面,load 操作相比 cmp 操作 慢非常非常多!!!,且load的值每次读取结果一样。
3. 所以,在第 2条的基础上。编译器对程序做出了优化,即JVM,不再真正重复 load 操作,假设 flage 是没有程序对它进行修改的操作。只进行读取一次。
4.实际上,是由其他线程(线程B)对它进行修改操作。而 JVM 对于这种(对同一个变量,一个线程读操作,一个线程修改操作)是会存在判定误差的!所以就出现BUG。
5. 结果:面对编译器JVM优化的,“好心办坏事”,需要程序员进行手动干预——volatile
4.volatile (解决内存可见性问题)
对于会出现 “内存可见性,线程不安全” 的变量(例如上述 flage)加上volatile 关键字,进行修饰。告诉编译器,这个变量是 “易变的” ,每一次都要重新读取变量的值。
volatile只能修饰成员变量(局部变量的生命周期定义,天然就规避了线程安全问题)
回到程序:可见程序达到预期效果。
注意 !!!!:
类似于上述编译器优化出现的问题,并不是始终都会出现的。例如,上述的代码,即使 flage 没有 volatile 进行修饰。但如果对线程A,在while()循环里,添加了 sleep 操作(休眠线程),控制了循环速度,那么,就可以避免内存可见性问题。
但是,编译器的优化,无法从代码层面感知,所以,建议,对有可能出现线程安全问题的 变量,应该都加上 volatile 修饰。
举例:代码达到预期效果
注意:volatile不保证原子性,它只针对一个线程读,一个线程修改的并发执行带来的线程安全问题。并不能解决两个线程对同一个变量进行修改操作的并发放执行,带来的安全问题
5. wait 和 notify (协调多个线程之间的执行先后顺序)
由于线程之间,是抢占式执行。线程执行顺序是不可预估的。但在实际开发中,更希望合理的协调多个线程之间的执行顺序
所以,为了完成协调工作。就有三个方法
wait() / wait(long timeout) 让当前线程进入等待状态。
notify () / notifyAll() 唤醒在当前对象上等待的线程。
注:wait() , notify() , notifyAll() , 三个方法都属于Object类。
wait 方法不带有参数,表示一直处于等待,当前线程处于阻塞状态,直到被其他线程唤醒。
而带有参数 wait(long timeout), 表示有个最大等待时间,参数就为最大等待时间。到时间了,就不等了。
(1) wait 操作流程
- 先释放锁
- 进入阻塞等待状态
- 收到通知后,在尝试重新获取锁,,并在获取锁后,继续往下执行。
(2) 代码演示,直观了解
class Myclass{
int a=0;
}
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Myclass myclass =new Myclass();
Thread thread1 = new Thread(()->{
System.out.println("线程1:使用wait之前:");
try {
synchronized (myclass){ //给Myclass这个对象加锁。
myclass.wait(); //给Myclass这个对象释放锁,但让线程进入阻塞状态。
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1:使用wait之后");
});
Thread thread2 = new Thread(()->{
System.out.println("线程2:使用notify之前");
synchronized (myclass){ //因为,notify务必先获取锁,才能进行通知
myclass.notify(); //而 线程1,已经对Myclass 这个对象释放锁了,所以再次加锁
}
System.out.println("线程2:使用notify之后");
});
thread1.start();
Thread.sleep(500);
thread2.start();
}
运行结果
注意!!!:
(3)总结:
1,上述代码执行过程:
线程1执行,调用 wait ------->线程1阻塞------>线程2执行,调用notify,唤醒线程1------>线程1继续执行。
2,wait() 无参数版,有缺陷。如果没有notify唤醒,wait 将会一直死等下去。相应的线程会一直陷入阻塞状态。
3,wait(long timeout) 有参数版 与sleep 功能貌似很像,但本质上有区别:
wait 和 sleep 都可被提前唤醒,但wait是被 notify 唤醒,属于程序的正常执行。但sleep被提前唤醒,是因为触发了异常处理,说明程序出现异常
4,如果有多组线程处于等待状态(wait), notify唤醒线程时,不能指定唤醒,只能随机唤醒某一线程
如果,要处理多组线程,可以将其分组确定顺序——例如:
假设有:线程1,线程2,线程3
需求:执行顺序:1,3,2
分组:(1,3),(3,2)这样就可以使用配套的wait 和 notify 先确定第一组的顺序,在确定第二组顺序。达到需求。
(4) notifyAll()
notifyAll() 和 notify 的区别是,在多个线程时,前者只唤醒一个线程。而后者是唤醒所有等待的线程,然后被唤醒的线程在一起竞争锁