目录
一.start():启动一个线程
调用start()方法
start()方法只能调用一次:
java%E4%B8%AD%E7%9A%84API%3A-toc" style="margin-left:40px;">java中的API:
start()和run()的区别:
二.中断一个线程
中断线程方法1:引入标志位
中断线程方法2:调⽤interrupt()⽅法
抛出的异常:
三.等待一个线程 join()
四、获取线程引用
五。线程的状态
六、线程安全(重点,难点)
引起线程不安全的原因:
解决方法:
“加锁”
“可重入”性
死锁
java%E6%A0%87%E5%87%86%E5%BA%93%E4%B8%AD%E7%9A%84%E7%BA%BF%E7%A8%8B%E5%AE%89%E5%85%A8%E7%B1%BB%EF%BC%9A-toc" style="margin-left:40px;">java标准库中的线程安全类:
内存可见性
一.start():启动一个线程
之前我们已经看到了如何通过重写run⽅法创建⼀个线程对象,但线程对象被创建出来并不意味着线程就开始运⾏了。重写run⽅法是提供给线程要做的事情的指令清单.
调用start()方法
调⽤start⽅法,才真的在操作系统的底层创建出⼀个线程.
start()方法只能调用一次:
对于同一个线程对象来说,start()方法只能调用一次.
若调用多次,除第一次的调用,之后的线程就会出现 illegalThreadStateException(非法线程状态异常)异常终止,而第一次调用的线程还能正常运行.
要想启动更多的线程,就要创建新的线程对象.
这里的main线程,和两个t1,t2 线程都是每隔一秒执行一次,可以看出打印结果 main和thread也是正如分析的一样.
在jconsloe.exe上也能看到三个线程的执行.
java%E4%B8%AD%E7%9A%84API%3A">java中的API:
API 是应用程序编程接口(Application Programming Interface)的缩写。
API 的结构:
在 Java 中,API(应用程序编程接口)指的是整个包、接口、类、方法等以及它们之间的关系和规范。API 是开发者用来构建应用程序的工具集,它包括了所有这些元素.
Java API ,指的是整个 Java 标准库,包括所有的包、接口、类、方法和异常等。这些元素共同构成了 Java 语言的核心功能,使得开发者能够构建各种应用程序,从简单的命令行工具到复杂的企业级应用。
它是一套预定义的函数、方法或类的集合,允许应用程序访问某些功能或数据,而无需关心底层的实现细节。API 为开发者提供了构建软件应用的积木。
start()和run()的区别:
start()方法:
调用start()是创建了一个新的线程:main线程,和t线程,两个线程同时工作,互不干扰。(并行执行)
通过Thread类调用start,开启一个线程,此时该线程处于就绪状态,并没有执行,一旦得到cpu的时间片,就自动调用run方法,开始执行。注意:无需等run方法结束,即可执行下面的代码。
run()方法:
而若单调用run()方法,run()方法只是类的一个普通方法而已。只是在main()线程中,去执行了一个run()方法,该方法执行完后,再去执行后面的代码,属于串行执行。注意:这里不会创建新线程。
总结:run()就是一个普通的方法,而start()会创建一个新线程去执行run()的代码。
1、start方法用来启动相应的线程;
2、run方法只是thread的一个普通方法,在主线程里执行;
3、需要并行处理的代码放在run方法中,start方法启动线程后自动调用run方法;
4、run方法必去是public的访问权限,返回类型为void。
此处,调用run()方法后,就只能去执行run()方法中的代码,而main()方法调用run()方法后面的代码,只有执行完run()方法后,才能去执行。(属于一条路线)
参考博客:
线程中start方法和run方法的区别_线程start和run区别-CSDN博客
二.中断一个线程
一个线程在执行过程中,因某些需要,要让该线程中断,不再执行.就需要对该线程进行中断处理。
(就好像你在打游戏,突然来了个电话,就要先中断你的游戏,去接听电话)
方法1:引入标志位
通过共享的标记来进⾏沟通(这需要线程之间的代码逻辑的配合执行)
设置静态变量,通过对其修改,来实现中断线程的功能
注意:这里的isQuite是设置在全局变量处的,而不能设置在main线程中,
原因是run()方法是通过使用lambda表达式(匿名内部类)来实现的,但lambda函数中的变量要遵循变量捕获原则,就是内部用到的局部变量不能是可以修改的,而此处的isQuite又需要对其修改,因此不能设置成fianl类型的。
但lambda表达式可以访问到方法外定义的任意变量,因此, 就只能设置成全局变量了.
lambda表达式中,不允许存在可能被修改的变量的原因是:
这里结果,执行完"3s后 Thread线程结束",在将isQuite设置为true之前,又执行了一次t线程,才结束t线程.
这里执行完"3s后 Thread线程结束",直接将isQuite设置为true,结束t线程。
可见线程的执行顺序和执行时间是随机的.
方法2:调⽤interrupt()⽅法
isInterrupted():判定标志位
interrupt():设置标志位
将run方法内的循环条件设置为判定标志位,再在调用标志位,使其改变,达到中断线程的效果。
但这样在执行的时候会抛出一个异常
在sleep()函数,当主动让t线程结束(修改interrupted标志位)的时候,此时sleep()的执行还未结束,当sleep()被提前唤醒的时候,会自动清除interrupted标志位.就会出现矛盾:到底是让该线程结束,还是继续执行.
要不想让异常终止,只需要修改异常内容就可以.
抛出的异常:
旧版的idea是执行try-catah后,catch里的代码是自动打印调用栈.
新版的idea是执行try-catah后,catch中再抛出一个异常.
但是在实际开发过程中,catch对以上两种方法都不用,idea生成的这两种方法都不用,这只是一个站位的作用.
在实际开发中,catch代码块中实际可能会进行如下操作:
在java中.程序的终止,是一种"软性"操作.就是说,需要线程中的代码配合,才能达到中断的效果.
三.等待一个线程 join()
因为多线程是随机调度的,有时,我们需要等待⼀个线程完成它的⼯作后,才能进⾏⾃⼰的下⼀步⼯作。
为了实现这种效果,该方法就能解决这样的问题的。等待一个线程,指的是让一个线程执行结束,再进行之后的执行.
多用于一个线程不确定执行时间,且要等待该线程结束,再进行别的线程操作.
下面的代码实现这样的功能:在t线程中执行1到5的相加运算,再在main线程中将结果打印
java">package Thread_;
public class Thread11 {
private static int count;
public static void main(String[] args) throws InterruptedException {
//计算1--5相加,再在main线程中打印结果
Thread t=new Thread(()->{
for(int i=0;i<5;i++){
count+=i;
}
System.out.println("Thread线程执行结束!");
});
t.start();
t.join();//线程等待
System.out.println("结果为: "+count);
}
}
join()的功能:在哪个线程中调用被调用,就暂停该线程(进入阻塞状态),哪个线程调用该方法,就先执行哪个线程.
join()方法有一个受查异常InterruptedException,使用时需要处理。
上面的代码中,join方法在main线程中被调用,则main就进入阻塞状态。t线程调用了该方法,则要等t线程执行结束,才继续执行main线程。(就是说:main线程要等t线程执行结束之后,main才能继续执行)
就是因为阻塞,使这两个线程结束产生了先后关系。
//计算1--10000相加,分成两个线程执行,再在main线程中打印结果
java">private static int count;
public static void main(String[] args) throws InterruptedException {
//计算1--10000相加,分成两个线程执行,再在main线程中打印结果
Thread t1=new Thread(()->{
int n=0;
//t1: 计算1到5000的相加
for(int i=1;i<=5000;i++){
n+=i;
}
count+=n;
System.out.println("Thread线程执行结束!");
});
Thread t2=new Thread(()->{
int n=0;
for(int i=5001;i<=10000;i++){
n+=i;
}
count+=n;
System.out.println("Thread线程执行结束!");
});
t1.start();
t2.start();
t1.join();//线程等待
t2.join();//线程等待
System.out.println("结果为: "+count);
}
此时的结果是正确的,但是若进行更大数字的相加时,
让其计算1到100亿数字的相加,算一下执行时间。
一个线程完成计算:
java"> private static long count;
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
for(long i=0;i<100_0000_0000L;i++){
count+=i;
}
});
t.start();
long beg=System.currentTimeMillis();
t.join();
long end=System.currentTimeMillis();
System.out.println("count= "+count);
System.out.println("运算时间为: "+(end-beg));
}
运行时间为3074ms.
两个线程完成计算:t1负责完成前50亿的计算,t2负责完成后50亿的计算。
java">private static long count;
public static void main(String[] args) throws InterruptedException {
//计算1--100_0000_0000(100亿)相加,分成两个线程执行,再在main线程中打印结果
Thread t1=new Thread(()->{
long n=0;
//t1: 计算1到5000的相加
for(long i=1;i<=50_0000_0000L;i++){
n+=i;
}
count+=n;
System.out.println("Thread线程执行结束!");
});
Thread t2=new Thread(()->{
long n=0;
for(long i=50_0000_0001L;i<=100_0000_0000L;i++){
n+=i;
}
count+=n;
System.out.println("Thread线程执行结束!");
});
t1.start();
t2.start();
t1.join();//线程等待
t2.join();//线程等待
System.out.println("结果为: "+count);
}
运行时间为1827ms.
双线程运算时间缩短,但此时也会存在线程安全的问题.
join()方法还有别的重载方法:
join():属于死等,t2线程不结束,就不会向下执行,
join(long millis):超时等待。在millis时间内,若t2线程没有结束,就不再等待,进行正常的代码流程。
第三个是设置一个ns级的时间,过于精确,用处不大。
要想把被join()阻塞的进程提前唤醒,也是可以通过interrupt()方法,将其唤醒。
四、获取线程引用
Thread.currenThread():获取当前线程的引用(Thread的引用)
如果是继承Thread,则可直接是由this调用;
若是实现Runnable接口或lambda表达式,此时this就不能代替Thread了,只能使用Thread.currendThread().
五。线程的状态
就绪状态:线程正在执行,或者随时准备着CPU的调用,执行的状态。
阻塞状态:线程暂时不方便去CPU上执行。
java中,线程有以下这几种状态:
1、NEW:Thread线程创建好了,但是还未调用start()方法。且直有处于NEW状态的线程才能调用start().
2、TERMINATED:Thread对象仍然存在,但是该线程已经执行完毕。
3、RUNNABLE:就绪状态,线程正在执行,或者随时准备着CPU的调用,去CPU上执行。
4、TIMED_WAITING:指定时间的阻塞状态,达到一定时间后,自动解除阻塞
5、WAITING:无时间限制的阻塞(死等),直有满足指定条件,才会结束阻塞。(join()/wain()都会进入WAITING状态)
6、BLOCK:由于锁竞争引起的阻塞。(存在线程安全的问题)
各状态的转换关系:
了解这些状态后,对代码的调试起到非常大的帮助
在jconsloe.exe中,也能看到线程的状态:
六、线程安全(重点,难点)
某个代码,若不论是在单线程下执行,还是在多线程下执行,都不会出现bug,这样的线程称为“线程安全”。
若在单线程下运行正确,在多线程下,就可能产生bug,这样的线程就是称为“线程不安全”的,或叫存在“线程安全”问题。
1.用一个线程计算1到10000的和,main线程打印结果:
结果正确.
2.用两个线程 t1计算1-5000的和,t2计算5001-10000的和,main打印结果:
该方法运行几次,发现每次的执行结果不确定,并且结果还是错误的。这就属于存在“线程安全问题”的代码。
这里的count++,在系统的底层其实是执行的三个cpu指令:
1、load:从内存读取数据到cpu寄存器上。
2、add:将寄存器中的值+1.
3、save:将寄存器中的值写回内存中。
两个线程执行的三个cpu指令可能有各种顺序。
列出几种情况:
但是无数种情况中,只有在一个线程从load到save执行完毕后,再去执行下一个线程的load,才能得到正确结果。
在5万次的自增过程中,也不知道多少次是正确的执行顺序.这也是为啥采用两个线程计算时,每次的结果不但错误,且不一样.
引起线程不安全的原因:
1.操作系统上的线程是“抢占式线程”,“随即调度”的。这给线程之间的执行顺序带来了很多变数。(根本原因)
2、代码结构上:代码中存在多个线程同时修改一个变量。
(一个线程修改一个变量,或多个线程读取一个变量,或是多个线程修改多个变量,这些都不会引起线程安全问题)
3.上面的线程修改操作(load->add->save),不是“原子的”操作(要莫不执行,要么执行完)。(直接原因)
不是“原子的“指的是,一个线程上的这些指令,执行到一半,可能会被调度走,让其他线程继续执行。而每个cpu指令(load,add,save....单个来看)都是原子的(要不不执行,要不执行完)。
4、内存可见性问题。
5、指令重排序问题。
解决方法:
1、针对线程的“抢占式线程”,“随即调度”。
,
2.代码结构上:可以不让多个线程同时修改一个变量,但这个要分情况,有时可以调整,但有时是无法实现调整的。
3、不是“原子的”操作:可以将count++生成的几个指令,通过一些方法,将其打包,使其成为一个“整体”。
“加锁”
可以通过“加锁”,来实现这样的效果。锁具有“互斥”,“排他”这样的特性。
在java中,加锁的方式有好多种,最主要使用的方式是通过加synchronized关键字,来加锁。
加锁的目的是,把count++的三个操作(load,add,save)打包成一个原子操作。
但这里进行锁操作,需要先准备一个“锁对象”,加锁,解锁的操作都是依托锁对象来执行的。
synchronized(对象){ }
进入{}后,会进行加锁(lock),出{}后,进行解锁(unlock)。
在java中,任何一个对象都可以成为锁对象。也就是说()中的内容可以是随意的,但必须为对象。
如果一个线程,针对一个对象加上锁之后,若别的线程也想对这个对象上锁,该线程就会产生阻塞(BLOCKED),直到上一个线程解锁为止,该线程才能继续操作。
解析:每次count++之前,进行上锁,count++之后,进行解锁。
若两个对象针对不同的对象加锁,则就不会有锁竞争,也不会产生阻塞。此时还是会存在线程安全问题。
这里能否通过上锁解决线程安全问题,最主要的就是是否对同一个对象上锁。
synchronized是调用系统的api进行加锁的,系统的api本质上是靠cpu上的特定指令完成加锁的。
通过锁竞争,让第二个线程的指令无法插入到第一个线程的执行指令之间,而不是禁止第一个线程被调度出cpu.
若一个线程加锁,另一个线程不加锁,又会怎样呢?
通过结果可以看出,仅对一个线程加锁,是无法解决线程安全问题的。未加锁的线程中的count++操作,仍然会被另一个线程插队。
synhrionzed的别的加锁方式:
java">class Test{
public int count1;
synchronized public void add(){
count1++;
}
}
public class Thread13 {
//线程安全问题
public static void main(String[] args) throws InterruptedException {
// Object block = new Object();
Test test = new Test();
Thread t1=new Thread(()->{
for(int i=1;i<=10000;i++){
test.add();//将count++放到类的方法中,对该类进行加锁
}
});
Thread t2=new Thread(()->{
for(int i=1;i<=10000;i++){
test.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+test.count1);
}
}
或者是以这种形式:
这都是对同一个对象加锁,这等价于将锁加到方法上。类似于上面的代码
若是synchronized加到静态方法上,则相当于对类进行了加锁。
后两个原因之后再解释。
关于String:
“可重入”性
若加两层锁,会怎样呢?
感觉这样写,会进入一个死等的状态,但结果:
正常输出!!!
这又是什么原因呢?
是因为加的两次锁,是在同一个线程中进行的,在第二次加锁的时候,知道该线程已经加过锁了,就不会进行阻塞,会继续执行代码,这个特性称为“可重入”。
使用“可重入”性,就会避免类似上面的代码出现阻塞状态。
如果没有“可重入”性,当写的代码非常复杂时,就非常容易出现这样的阻塞状态。因为加锁的代码可能会非常隐蔽。
底层内部实现可重入行:
有一个计数器,最初为0,在第一次加锁的时候,计数器+1,同时记录是哪个线程加的锁;
在第二次加锁的时候,此时计数器为1,判定持有锁线程和加锁线程是否为同一个线程,若为同一个线程,说明该线程已经加过锁了,就不再加锁了,仅对计数器+1;若不为同一线程,则加锁线程就会进入阻塞状态。
解锁的时候,是从内层向外层以此解锁,每到 },计数器就-1,当计数器为0,就真正实现解锁了。
注意:整个过程只有一把锁,
死锁
加锁能够解决线程安全问题,但是若加锁处理不当,就可能产生死锁。
产生死锁的四个必要条件(全部具备,才会产生死锁):
1、互斥使用:一个线程获取到了这把锁,另一个线程也想获取这把锁,就进入了阻塞状态。
2、不可抢占:一个线程拿到这把锁之后,只能主动解锁,别的线程不能强行把锁抢走。
3、请求和保持:一个线程获取锁A之后,尝试获取锁B.
4、循环等待:该线程尝试获取锁,进入阻塞状态,未获取到,就一直处于阻塞状态。
死锁的三种场景:
1、一个线程,一把锁:就像上面的在一个线程内,两次获取同一把锁,若没有可重入性,则该线程就会进入死锁状态。
2、两个线程,两把锁:线程1获取到了锁A,线程2获取到了锁B;接下来,线程1尝试获取锁B,线程2尝试获取锁A。两个线程都不能获取到,都进入了阻塞状态,就产生了死锁。
3.M各线程N把锁:
最经典的问题:哲学家就餐问题:假设5个哲学家就餐,但直有5根筷子,
针对上述问题,解决死锁,有几种方法:
1、加一个筷子(加一把锁)。
2、减少一个哲学家(减少一个线程)。
3、让线程获取琐时,按规定顺序获取。(给锁编一个号,让线程从小到大获取锁)(这种方法比较常用)
4、银行家算法。(比较复杂,先不讨论)
java%E6%A0%87%E5%87%86%E5%BA%93%E4%B8%AD%E7%9A%84%E7%BA%BF%E7%A8%8B%E5%AE%89%E5%85%A8%E7%B1%BB%EF%BC%9A">java标准库中的线程安全类:
Java标准库中很多都是线程不安全的.这些类可能会涉及到多线程修改共享数据,⼜没有任何加锁措 施:
这些在java标准库中,都准备弃用了。
内存可见性
如果一个线程写,一个线程读,是否会引起线程安全问题呢?
在t1线程中,设置循环条件:判断flag是否被修改,在mian线程中通过控制台输入,修改flag:
输入1后,t1线程并没有按照预期结果结束执行!
说明引起了线程安全问题。
这是因为和内存可见性问题有关。
在t1线程的while循环中会执行两条核心指令:
1、load:读取内存中flag的值到cpu寄存器上。
2.字节跳转指令:将cup寄存器上的值和0进行比较。
在用户输入值之前,t1线程已经进行过多次循环了(上亿次),其中load每次从内存中读取的值都是相同的,并且load操作的开销远超过字节跳转(访问寄存器的速度远远超过访问内存)
这之后,再修改flag的值,就没有作用了。
volatile关键字
想让自己写的代码,无论什么情况都不会出现内存可见性问题,可以用volatile关键字来修饰,这样就可以使上述优化强制关闭,保证每次循环都是从内存中读取数据的。(同时,也降低了代码执行的效率)
volatile关键字的作用主要有如下两个:
保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
注意:volatile 不能保证原子性
引入volatile关键字,相当于把主动权交给了程序员自己。