文章目录
-
- 前言
- 一、初识多线程
- 二、线程的基本使用
-
- 2.1 创建线程
- 2.2 启动线程
- 2.3 线程属性
- 2.4 线程的生命周期
- 2.5 Thread类的常用方法
- 2.6 线程相关的一些问题
-
- 2.6.1 interrupt、interrupted和isInterrupted方法的区别
- 2.6.2 sleep方法和yield方法有什么区别
- 2.6.3 线程怎么处理异常
- 2.6.4 Thread.sleep(0)的作用是什么
- 2.6.5 一个线程如果出现了运行时异常会怎么样
- 2.6.6 终止线程运行的几种情况
- 2.6.7 如何优雅地设置睡眠时间
- 2.6.8 如何设置上下类类加载器
- 2.6.9 如何停止一个正在运行的线程
- 2.6.10 为什么Thread类的sleep()和yield()方法是静态的
- 2.6.11 怎么检测一个线程是否拥有锁
- 2.6.12 线程的调度策略
- 2.6.13 线程的调度策略
- 2.6.14 join可以保证线程执行顺序的原理
- 2.6.15 stop()方法和interrupt()方法的区别
- 2.7 多线程使用示例
- 三、线程的活性故障
前言
- 计算机的组成
一个程序要运行,首先要被加载到内存,然后数据被运送到CPU的寄存器里。寄存器用来存储数据;PC为程序计数器,用来记录要执行的程序的位置;算术逻辑单元执行具体的计算,然后将结果再传送给内存。
CPU执行运算的大致过程:CPU读取指令,然后程序计数器存储程序的执行位置,然后从寄存器中读取原始数据,计算完成后,再将结果返回给内存,一直循环下去。
线程之间的调度由线程调度器负责,确定在某一时刻运行哪个线程。
线程上下文切换,简单来说,指的是CPU保存现场,执行新线程,恢复现场,继续执行原线程的一个过程
。
一、初识多线程
1.1 并行、并发、串行
- 并发
多个任务在同一个CPU核上,按细分的时间片轮流执行,从逻辑上来看任务是同时执行。 - 并行
单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。 - 串行
有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
图示:
可以看出:串行是利用一个资源,依次、首尾相接地把不同的事情做完;并发也是利用一个资源,在做一件事时的空闲时间去做另一件事;并行是投入多个资源,去做多件事。
多线程编程的实质就是将任务的处理方式由串行改成并发
。
1.2 并发编程的优缺点
1.2.1 并发编程的优点
- 1、充分利用多核CPU的计算能力
可以真正发挥出多核CPU的优势来,达到充分利用CPU的目的,采用多线程的方式去同时完成几件事情而不互相干扰。 - 2、方便进行业务拆分,提升应用性能
多线程并发编程是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
1.2.2 并发编程的缺点
- 1、频繁的上下文切换
时间片是CPU分配给各个线程的时间,因为时间非常短,所以CPU不断通过切换线程,达到一种"不同应用似乎是同时运行的错觉",时间片一般是几十毫秒。每次切换时,需要保存当前的状态起来,以便能够进行恢复先前状态,而这个切换时非常损耗性能,过于频繁反而无法发挥出多线程编程的优势。 - 2、产生线程安全问题
即死锁、线程饥饿等问题。
1.3 上下文切换
一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU采取的策略是交替地为每个线程分配时间片。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就是上下文切换
。
概括来说:当前任务在执行完CPU时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换
。
在时间片切换到别的任务和切换到当前任务的时候,操作系统需要保存和恢复相应线程的进度信息。这个进度信息就是上下文
,它一般包括通用寄存器的内容和程序计数器的内容。
使用vmstat可以测量上下文切换的次数。示例:
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 127876 398928 2297092 0 0 0 4 2 2 0 0 99 0 0
0 0 0 127868 398928 2297092 0 0 0 0 595 1171 0 1 99 0 0
0 0 0 127868 398928 2297092 0 0 0 0 590 1180 1 0 100 0 0
0 0 0 127868 398928 2297092 0 0 0 0 567 1135 0 1 99 0 0
CS(Content Switch)表示上下文切换的次数,例子中的上下文每1秒切换1000多次。
1.3.1 上下分切换的分类
上下文切换可以分为自发性上下文切换和非自发性上下文切换(通常说的上下文切换指的是第一种):
类型 | 含义 | 原因 |
---|---|---|
自发性上下文切换 | 由于自身因素导致的切出 | Thread.sleep(long mills); Object.wait(); Thread.yiels(); Thread.join(); LockSupport.park(); 线程发起了IO操作; 等待其他线程持有的锁 。 |
非自发性上下文切换 | 由于线程调度器的原因被迫切出 | 当前线程的时间片用完; 有一个比当前线程优先级更高的线程需要运行; Java虚拟机的垃圾回收动作。 |
1.3.2 减少上下文切换的方式
- 1、无锁并发编程
类似ConcurrentHashMap锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。 - 2、CAS算法
利用Atomic下使用CAS算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换。 - 3、使用最少线程
避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态。 - 4、协程
在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
1.3.3 上下文切换的优化示例
- 1、用jstack命令dump线程信息
此处查看pid为3117的进程:
sudo -u admin /opt/ifeve/java/bin/jstack 31177 > /home/tengfei.fangtf/dump17
- 2、统计所有线程分别处于什么状态
发现300多个线程处于WAITING(onobject-monitor)状态:
[tengfei.fangtf@ifeve ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}'
| sort | uniq -c
39 RUNNABLE
21 TIMED_WAITING(onobjectmonitor)
6 TIMED_WAITING(parking)
51 TIMED_WAITING(sleeping)
305 WAITING(onobjectmonitor)
3 WAITING(parking)
- 3、打开dump文件查看处于WAITING(onobjectmonitor)的线程在做什么
发现这些线程基本全是JBOSS的工作线程,在await。说明JBOSS线程池里线程接收到的任务太少,大量线程都闲着:
"http-0.0.0.0-7001-97" daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in
Object.wait() [0x0000000052423000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker)
at java.lang.Object.wait(Object.java:485)
at org.apache.tomcat.util.net.AprEndpoint$Worker.await(AprEndpoint.java:1464)
- locked <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker)
at org.apache.tomcat.util.net.AprEndpoint$Worker.run(AprEndpoint.java:1489)
at java.lang.Thread.run(Thread.java:662)
- 4、做出优化
减少JBOSS的工作线程数,找到JBOSS的线程池配置信息,将maxThreads降到100:
<maxThreads="250" maxHttpHeaderSize="8192"
emptySessionPath="false" minSpareThreads="40" maxSpareThreads="75"
maxPostSize="512000" protocol="HTTP/1.1"
enableLookups="false" redirectPort="8443" acceptCount="200" bufferSize="16384"
connectionTimeout="15000" disableUploadTimeout="false" useBodyEncodingForURI= "true">
- 5、验证
重启JBOSS,再dump线程信息,然后统计WAITING(onobjectmonitor)的线程,发现减少了175个。WAITING的线程少了,系统上下文切换的次数就会少,因为每一次从WAITTING到RUNNABLE都会进行一次上下文的切换。
[tengfei.fangtf@ifeve ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}'
| sort | uniq -c
44 RUNNABLE
22 TIMED_WAITING(onobjectmonitor)
9 TIMED_WAITING(parking)
36 TIMED_WAITING(sleeping)
130 WAITING(onobjectmonitor)
1 WAITING(parking)
1.4 并发编程三要素
- 线程安全
一般而言,如果一个类在单线程环境下能正常运行,并且在多线程环境下也能正常运行,那么就称其是线程安全的。 - 线程不安全
一个类在单线程情况下能正常运行,但在多线程环境下无法正常运行,那么这个类就是非线程安全的。
线程安全问题概括来说表现为3个方面:原子性、可见性和有序性。
1.4.1 原子性
- 1、如何理解原子性
对于涉及共享变量的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性
。
原子性问题由线程切换导致。
原子性指的是一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断,要么就全部都不执行。
在理解原子操作时有两点需要注意:
- 原子操作是针对共享变量的操作而言的;
- 原子操作是在多线程环境下才有意义。
原子操作的“不可分割”具有两层含义:
- 1、访问(读、写)某个共享变量的操作,从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,不会“看到”该操作执行部分的中间效果。
- 2、访问同一组共享变量的原子操作是不能够被交错的。
在Java中,对基本数据类型数据(long/double除外,仅包括byte、boolean、short、char、float和int)的变量和引用型变量的写操作都是原子的
。
虚拟机将没有被volatile修饰的64位数据(long/double)的读写操作划分为两次32位的操作来进行。
如果要保证long/double的写操作具有原子性,可以使用volatile变量修饰long/double变量。值得注意的是:volatile关键字仅能保证变量写操作的原子性,并不能保证其他操作(如read-modify-write操作和check-then-act操作)的原子性
。
Java中任何变量的读操作都是原子操作。 - 2、原子性问题的例子
一个关于原子性的典型例子:counter++这并不是一个原子操作,包含了三个步骤:
- 读取变量counter的值;
- 对counter加一;
- 将新值赋值给变量counter。
- 3、解决原子性问题方法
Atomic开头的原子类、synchronized、LOCK等,都可以解决原子性问题
。
1.4.2 可见性
- 1、如何理解可见性
如果一个线程对某个共享变量进行更新后,后续访问该变量的线程可以读取到本次更新的结果,那么就称这个线程对该共享变量的更新对其它线程可见(一个线程对共享变量的修改,另一个线程能够立刻看到
)。
可见性问题由缓存导致。 - 2、如何实现可见性
主要有三种实现可见性的方式:
volatile
,通过在汇编语言中添加lock指令,来实现内存可见性。synchronized
,当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。final,被final关键字修饰的字段在构造器中一旦初始化完成,并且没有发生this逃逸(其它线程通过 this 引用访问到初始化了一半的对象)
,那么其它线程就能看见 final 字段的值。
- 3、一些可见性场景
Java中默认的两种可见性的存在场景:
- 父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。
- 一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。
1.4.3 有序性
有序性指的是:程序执行的顺序按照代码的先后顺序执行
。有序性问题由编译优化导致。
volatile和synchronized都可以保证有序性
:
- volatile关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
- synchronized关键字同样可以保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
1.5 同步与异步
- 同步
当一个同步调用发出去后,调用者要一直等待调用结果的返回后,才能进行后续的操作。 - 异步
当一个异步调用发出去后,调用者不用管被调用方法是否完成,都会继续执行后面的代码。 异步调用,要想获得结果,一般有两种方式:
- 主动轮询异步调用的结果;
- 被调用方通过callback来通知调用方调用结果(常用)。
比如在超市购物,如果一件物品没了,你等仓库人员跟你调货,直到仓库人员跟你把货物送过来,你才能继续去收银台付款,这就类似同步调用。而异步调用就像网购,在网上付款下单后就不用管了,当货物到达后你收到通知去取就好。
1.6 进程与线程
程序是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。
进程是程序向操作系统申请资源的基本单位,线程是进程中可独立执行的最小单位
。通常一个进程可以包含多个线程,至少包含一个线程,同一个进程中所有线程共享该进程的资源。
- 1、根本区别
进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
。 - 2、资源开销
每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。 - 3、包含关系
一个进程里可以包含多个线程。 - 4、内存分配
同一进程的线程共享本进程的地址空间和资源,而线程之间的地址空间和资源是相互独立的
。 - 5、影响关系
一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。 - 6、执行过程
每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中
,由应用程序提供多个线程执行控制,两者均可并发执行。
1.7 线程调度
一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令。多线程的并发运行,指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务。
线程调度模型有两种:
- 1、分时调度模型
分时调度模型是指让所有的线程轮流获得CPU的使用权,并且平均分配每个线程占用的CPU的时间片。 - 2、抢占式调度模型
抢占式调度模型是指优先让运行池中优先级高的线程占用CPU,如果运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃CPU。
Java虚拟机(JVM)采用抢占式调度模型
。
1.8 编写多线程程序的时候你会遵循哪些最佳实践?
1)给线程命名,这样可以帮助调试。
2)最小化同步的范围,而不是将整个方法同步,只对关键部分做同步。
3)如果可以,更偏向于使用volatile而不是synchronized。
4)使用更高层次的并发工具,而不是使用wait()和notify()来实现线程间通信,如BlockingQueue、CountDownLatch及Semeaphore。
5)优先使用并发集合,而不是对集合进行同步。并发集合提供更好的可扩展性。
6)使用线程池。
1.9 线程安全的级别
- 不可变
不可变的对象一定是线程安全的,并且永远也不需要额外的同步。Java类库中大多数基本数值类如Integer、String和BigInteger都是不可变的。 - 无条件的线程安全
由类的规格说明所规定的约束在对象被多个线程访问时仍然有效,不管运行时环境如何排列,线程都不需要任何额外的同步。如Random 、ConcurrentHashMap、Concurrent集合、atomic。 - 有条件的线程安全
有条件的线程安全类对于单独的操作可以是线程安全的,但是某些操作序列可能需要外部同步。有条件线程安全的最常见的例子是遍历由Hashtable或者Vector或者返回的迭代器 - 非线程安全(线程兼容)
线程兼容类不是线程安全的,但是可以通过正确使用同步而在并发环境中安全地使用。如ArrayList HashMap。 - 线程对立
线程对立是那些不管是否采用了同步措施,都不能在多线程环境中并发使用的代码。如System.setOut()。
1.10 Linux环境下如何查找哪个线程使用CPU最长
- 1、获取项目的pid,jps或者ps -ef | grep java。
- 2、top -H -p pid,顺序不能改变。
使用"top -H -p pid"+"jps pid"可以很容易地找到某条占用CPU高的线程的线程堆栈,从而定位占用CPU高的原因,一般是因为不当的代码操作导致了死循环。
最后提一点,"top -H -p pid"打出来的LWP是十进制的,"jps pid"打出来的本地线程号是十六进制的,转换一下,就能定位到占用CPU高的线程的当前线程堆栈了。
二、线程的基本使用
在Java中创建一个线程,可以理解创建一个Thread类(或其子类)的实例。线程的任务处理逻辑可以在Thread类的run实例方法中实现,运行一个线程实际上就是让Java虚拟机执行该线程的run方法。run方法相当于线程的任务处理逻辑的入口方法,它由虚拟机在运行相应线程时直接调用,而不是由相应代码进行调用
。
启动一个线程的方法是调用start方法,其实质是请求Java虚拟机运行相应的线程,而这个线程具体何时运行是由线程调度器决定的
。因此,start方法调用结束并不意味着相应线程已经开始运行。
2.1 创建线程
创建线程有4种方式。
2.1.1 继承Thread类
使用方式:
- 继承Thread类;
- 重写run方法;
- 创建Thread对象;
- 通过start()方法启动线程。
示例:
/*继承Thread类*/
public class WelcomeThread extends Thread{
@Override
public void run() {
System.out.printf("test");
}
}
public class ThreadTest1 {
public static void main(String[] args) {
// 创建线程
Thread welcomeThread = new WelcomeThread();
// 启动线程
welcomeThread.start();
}
}
JDK1.8后,可以使用Lambda表达式来创建,示例:
new Thread(()->{
System.out.println("Lambda Thread Test!");
}).start();
2.1.2 实现Runnable接口
使用方式:
- 实现Runnable接口;
- 重写run方法;
- 创建Thread对象,将实现Runnable接口的类作为Thread的构造参数;
- 通过start()进行启动。
此种方式用到了代理模式,示例:
public class RunnableDemo implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo();
Thread thread = new Thread(runnableDemo);
thread.start();
}
}
前两种比较的话, 推荐使用第二种方式,原因:
- Java是单继承,将继承关系留给最需要的类。
Runnable可以实现多个相同的程序代码的线程去共享同一个资源
。当以Thread方式去实现资源共享时,实际上Thread内部,依然是以Runnable形式去实现的资源共享。
2.1.3 实现Callable接口
前两种方式比较常见,Callable的使用方式:
- 创建实现Callable接口的类;
- 以Callable接口的实现类为参数,创建FutureTask对象;
- 将FutureTask作为参数创建Thread对象;
- 调用线程对象的start()方法。
示例:
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
System.out.println(Thread.currentThread().getName() + " call()方法执行中...");
return 1;
}
}
public class CallableTest {
public static void main(String[] args) {
FutureTask<Integer> futureTask = new FutureTask<Integer>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
}
}
使用该方法创建线程时,核心方法是call(),该方法有返回值,其返回值类型就是Callable接口中泛型对应的类型
。
2.1.4 使用Executors工具类创建线程池
由于线程的创建、销毁是一个比较消耗资源的过程,所以在实际使用时往往使用线程池。
在创建线程池时,可以使用现成的Executors工具类来创建,该工具类能创建的线程池有4种:newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool。此处以newSingleThreadExecutor为例,其步骤为:
- 使用Executors类中的newSingleThreadExecutor方法创建一个线程池;
- 调用线程池中的execute()方法执行由实现Runnable接口创建的线程;或者调用submit()方法执行由实现Callable接口创建的线程;
- 调用线程池中的shutdown()方法关闭线程池。
示例:
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " run()方法执行中...");
}
}
public class SingleThreadExecutorTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newSingleThreadExecutor();
MyRunnable runnableTest = new MyRunnable();
for (int i = 0; i < 5; i++) {
executorService.execute(runnableTest);
}
System.out.println("线程任务开始执行");
executorService.shutdown();
}
}
2.1.5 4种创建方式对比
- 1、继承Thread类
优点 :代码简单 。
缺点 :该类无法继承别的类。
如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用 this 即可获得当前线程。 - 2、实现Runnable接口
实现Runnable接口,比继承Thread类所具有的优势:
1)适合多个相同的程序代码的线程去处理同一个资源;
2)可以避免Java中的单继承的限制;
3)增加程序的健壮性,代码可以被多个线程共享,代码和数据独立 ;
4)线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类;
5)Runnable实现线程可以对线程进行复用,因为Runnable是轻量级的对象,重复new不会耗费太大资源,而Thread则不然,它是重量级对象,而且线程执行完就完了,无法再次利用。 - 3、实现Callable接口
优点:可以获得异步任务的返回值。
如果要访问当前线程,则必须使用Thread.currentThread()方法。
线程类实现Runnable接口或Callable接口,还可以继承其他类。在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况。
- 4、线程池
优点:实现自动化装配,易于管理,循环利用资源。
2.2 启动线程
2.2.1 线程每次只能使用一次
当线程的run方法执行结束,相应的线程的运行也就结束了。
线程每次只能使用一次,即只能调用一次start方法
。在线程未结束前,多次调用start方法会抛出IllegalThreadStateException,Thread类中的start方法中可以看出该逻辑:
public synchronized void start() {
checkNotStarted();
hasBeenStarted = true;
nativeCreate(this, stackSize, daemon);
}
private void checkNotStarted() {
if (hasBeenStarted) {
throw new IllegalThreadStateException("Thread already started");
}
}
可以看出:start()方法使用synchronized关键字修饰,说明start()方法是同步的,它会在启动线程前检查线程的状态,如果不是初始化状态,则直接抛出异常。所以,一个线程只能启动一次,多次启动是会抛出异常的。
2.2.2 线程的run()和 start()有什么区别
两者的区别:
start()方法用于启动线程,run()方法用于实现具体的业务逻辑
。run()可以重复调用,而start()只能调用一次
。
调用start()方法来启动一个线程,无需等待run()方法体代码执行完毕,可以直接继续执行其他的代码, 此时线程是处于就绪状态,并没有运行。
当调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启动,start()方法才会启动新线程。
2.2.3 为什么不能直接调用run()方法
新建一个线程,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start()会执行线程的相应准备工作,然后自动执行run()方法的内容,这是真正的多线程工作。
如果直接执行run()方法,会把run方法当成一个main线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作
。示例:
public class JavaTest {
public static void main(String[] args) {
System.out.println("main方法中的线程名:"
+Thread.currentThread().getName()); //main方法中的线程名:main
Thread welcomeThread = new WelcomeThread();
System.out.println("以start方法启动线程");
welcomeThread.start(); //Thread子类中的线程名:Thread-0
System.out.println("以run方法启动线程");
welcomeThread.run(); //Thread子类中的线程名:main
}
}
class WelcomeThread extends Thread{
@Override
public void run() {
System.out.println("Thread子类中的线程名:"
+Thread.currentThread().getName());
}
}
总结: 调用start方法方可启动线程并使线程进入就绪状态
,而run方法只是thread的一个普通方法调用,还是在主线程里执行。
2.2.4 线程类的构造方法、静态块是被哪个线程调用的
线程类的构造方法、静态块是被new这个线程类所在的线程所调用的,而run方法里面的代码才是被线程自身所调用的。
举个例子,假设Thread2中new了Thread1,main函数中new了Thread2,那么:
- Thread2的构造方法、静态块是main线程调用的,Thread2的run()方法是Thread2自己调用的
- Thread1的构造方法、静态块是Thread2调用的,Thread1的run()方法是Thread1自己调用的。
2.3 线程属性
Thread类的私有属性有许多,了解几个常用的即可:线程的编号(ID)、名称(Name)、线程类别(Daemon)和优先级(Priority)。
这几个属性中,ID仅可读,其他都是可读写
。具体:
属性 | 属性类型 | 用途 | 注意事项 |
---|---|---|---|
编号(ID) | long | 用于标识不同的线程,不同的线程拥有不同的编号 | 某个编号的线程运行结束后,该编号可能被后续创建的线程使用,因此该属性的值不适合用作某种唯一标识 |
名称(Name) | String | 用于区分不同的线程,默认值的格式为“Thread-线程编号” | 尽量为不同的线程设置不同的值 |
线程类别(Daemon) | boolean | 值为true表示相应的线程为守护线程,否则表示相应的线程为用户线程。该属性的默认值与相应线程的父线程的该属性的值相同 | 该属性必须在相应线程启动之前设置,即调用setDaemon方法必须在调用start方法之前,否则会出现IllegalThreadStateException |
优先级(Priority) | int | 优先级高的线程一般会被优先运行。优先级从1到10,默认值一般为5(普通优先级),数字越大,优先级越高。 对于具体的一个线程而言,其优先级的默认值与其父线程的优先级值相等。 | 一般使用默认的优先级即可,不恰当地设置该属性值可能会导致严重的问题(线程饥饿) |
获取4个属性值示例:
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
//10,Thread-0,5,false
System.out.println(Thread.currentThread().getId()+","
+Thread.currentThread().getName()+","
+Thread.currentThread().getPriority()+","
+Thread.currentThread().isDaemon());
}
}).start();
}
2.3.1 线程优先级
Java线程的优先级属性本质上只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行
。每个线程的优先级都在1到10之间,1的优先级为最低,10的优先级为最高,在默认情况下优先级都是Thread.NORM_PRIORITY(常数 5)。
虽然开发者可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。
线程优先级特性:
- 1、继承性
比如A线程启动B线程,则B线程的优先级与A是一样的。 - 2、规则性
高优先级的线程总是大部分先执行完,但不代表高优先级线程全部先执行完。 - 3、随机性
优先级较高的线程不一定每一次都先执行完。
在不同的JVM以及OS上,线程规划会存在差异,有些OS会忽略对线程优先级的设定。
设置和获取线程优先级的方法:
//为线程设定优先级
public final void setPriority(int newPriority)
//获取线程的优先级
public final int getPriority()
示例:
public class RunnableDemo implements Runnable {
@Override
public void run() {
int nowPriority = Thread.currentThread().getPriority();
System.out.println("1.优先级:"+nowPriority); //1.优先级:5
Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
nowPriority = Thread.currentThread().getPriority();
System.out.println("2.优先级:"+nowPriority); //2.优先级:10
}
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo();
Thread thread = new Thread(runnableDemo);
thread.start();
}
}
2.3.2 守护线程和用户线程
Java 中的线程分为两种:守护线程和用户线程。任何线程都可以设置为守护线程和用户线程,通过方法setDaemon(true)
可以把该线程设置为守护线程,反之则为用户线程。
用户线程
:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程。
守护线程
:运行在后台,为其他前台线程服务,比如垃圾回收线程,JIT(编译器)线程就可以理解为守护线程。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作。
守护线程应该永远不去访问固有资源 ,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。
注意事项:
setDaemon(true)必须在start()方法前执行,否则会抛出IllegalThreadStateException
。- 在守护线程中产生的新线程也是守护线程。
- 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑。
- 守护线程中不能依靠finally块的内容来确保执行关闭或清理资源的逻辑。因为我们上面也说过了一旦所有用户线程都结束运行,守护线程会随JVM一起结束工作,所以守护线程中的finally语句块可能无法被执行。
设置和获取线程是否是守护线程的方法:
//设置线程是否为守护线程
public final void setDaemon(boolean on)
//判断线程是否是守护线程
public final boolean isDaemon()
2.3.3 线程名称
相比于上面的两个属性,实际运用中,往往线程名称会被修改,目的是为了调试。获取和设置线程名称的方法:
//获取线程名称
public final String getName()
//设置线程名称
public final synchronized void setName(String name)
示例:
public class RunnableDemo implements Runnable {
@Override
public void run() {
String nowName = Thread.currentThread().getName();
System.out.println("1.线程名称:"+nowName); //1.线程名称:Thread-0
Thread.currentThread().setName("测试线程");
nowName = Thread.currentThread().getName();
System.out.println("2.线程名称:"+nowName); //2.线程名称:测试线程
}
public static void main(String[] args) {
RunnableDemo runnableDemo = new RunnableDemo();
Thread thread = new Thread(runnableDemo);
thread.start();
}
}
2.4 线程的生命周期
2.4.1 从代码角度理解
在Thread类中,线程状态是一个枚举类型:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED
}
线程的状态可以通过public State getState()
来获取,该方法的返回值是一个枚举类型,线程状态定义如下:
- 1、NEW
一个已创建而未启动的线程处于该状态。 - 2、RUNNABLE
该状态可以被看成一个复合状态。它包括两个子状态:READY和RUNNING。前者表示处于该状态的线程可以被线程调度器进行调度而使之处于RUNNING状态,后者表示线程正在运行状态。
执行Thread.yield()的线程,其状态可能由RUNNING转换为READY。 - 3、BLOCKED
处于BLOCKED状态的线程并不会占处理器资源,当阻塞式IO操作完成后,或线程获得了其申请的资源,状态又会转换为RUNNABLE。 - 4、WAITING
一个线程执行了某些特定方法之后就会处于这种等待其他线程执行另外一些特定操作的状态。
能够使线程变成WAITING状态的方法包括:Object.wait()、Thread.join(),能够使线程从WAITING状态变成RUNNABLE状态的方法有:Object.notify()、Object.notifyAll()。 - 5、TIMED_WAITING
该状态和WAITING类似,差别在于处于该状态的线程并非无限制地等待其他线程执行特定操作,而是处于带有时间限制的等待状态。
当其他线程没有在特定时间内执行该线程所期待的特定操作时,该线程的状态自动转换为RUNNABLE。 - 6、TERMINATED
已经执行结束的线程处于该状态。
Thread.run()正常返回或由于抛出异常而提前终止都会导致相应线程处于该状态。
6种状态的转换:
2.4.2 从使用角度理解
在实际开发中,往往将线程的状态理解为5种:新建、可运行、运行、阻塞、死亡。
- 1、新建(new)
新创建了一个线程对象。用new方法创建一个线程后,线程对象就处于新建状态。 - 2、可运行(runnable)
线程对象创建后,当调用线程对象的start()方法,该线程处于就绪状态,但还没分配到CPU,处于线程就绪队列,等待系统为其分配CPU。 - 3、运行(running)
可运行状态(runnable)的线程获得了CPU时间片,执行程序代码。
就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
如果在给定的时间片内没有执行结束,就会被系统给换下来回到等待执行状态。 - 4、阻塞(block)
处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪(runnable)状态,才有机会再次被CPU调用以进入到运行状态。
阻塞的情况分三种:
等待阻塞
(位于对象等待池中的阻塞)
运行状态中的线程执行wait()方法,JVM会把该线程放入等待队列中,使本线程进入到等待阻塞状态;同步阻塞
(位于对象锁池中的阻塞)
线程在获取synchronized同步锁失败(因为锁被其它线程所占用),则JVM会把该线程放入锁池中,线程会进入同步阻塞状态;其他阻塞
通过调用线程的sleep()或 join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
- 5、死亡(dead)
线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。
2.5 Thread类的常用方法
以下是Thread类中较常用的几个方法,并不包含线程间协作的方法(如await、notify等),这些方法的使用随后介绍。其中的yield方法并不常用,但常常拿来和sleep、await等方法进行比较,所以也介绍下。
方法 | 功能 | 备注 |
---|---|---|
static Thread currentThread() | 返回当前线程,即当前代码的执行线程 | |
void run() | 用于实现线程的任务处理逻辑 | 该方法由Java虚拟机直接调用 |
void start() | 启动线程 | 调用该方法并不代表相应的线程已经被启动,线程是否启动是由虚拟机去决定的 |
void join() | 等待相应线程运行结束 | 若线程A调用线程B的join方法,那么线程A的运行会被暂停,直到线程B运行结束 |
static void yield() | 使当前线程主动放弃其对处理器的占用,这可能导致当前线程被暂停 | 这个方法是不可靠的,该方法被调用时当前线程仍可能继续运行 |
void interrupt() | 中断线程 | |
static void sleep(long millis) | 使当前线程休眠(暂停运行)指定的时间 |
2.5.1 interrupted
中断
可以理解为线程的一个标志位,它表示了一个运行中的线程是否被其他线程进行了中断操作,常常被用于线程间的协作
。
其他线程可以调用指定线程的interrupt()方法对其进行中断操作,同时指定线程可以调用isInterrupted()来感知其他线程对其自身的中断操作,从而做出响应。
另外,也可以调用Thread的静态方法interrupted()对当前线程进行中断操作,该方法会清除中断标志位。需要注意的是,当抛出InterruptedException时候,会清除中断标志位
,此时再调用isInterrupted,会返回false。
和中断相关的方法有3个:
方法名 | 详细解释 | 备注 |
---|---|---|
public void interrupt() | 中断该线程对象 | 如果该线程被调用了Object wait/Object wait(long),或者被调用sleep(long),join()/join(long)方法时会抛出interruptedException并且中断标志位将会被清除 |
public boolean isinterrupted() | 测试该线程对象是否被中断 | 中断标志位不会被清除 |
public static boolean interrupted() | 查看当前中断信号是true还是false并且清除中断信号 | 中断标志位会被清除 |
关于interrupt和isinterrupted的使用,示例:
public class JavaTest {
public static void main(String[] args) throws InterruptedException {
//sleepThread睡眠1000ms
final Thread sleepThread = new Thread() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
super.run();
}
};
//busyThread一直执行死循环
Thread busyThread = new Thread() {
@Override
public void run() {
while (true) ;
}
};
sleepThread.start();
busyThread.start();
sleepThread.interrupt();
busyThread.interrupt();
while (sleepThread.isInterrupted()) ;
System.out.println("sleepThread isInterrupted: " + sleepThread.isInterrupted());
System.out.println("busyThread isInterrupted: " + busyThread.isInterrupted());
}
}
测试结果:
在上面的代码中,开启了两个线程分别为sleepThread和BusyThread, sleepThread睡眠1s,BusyThread执行死循环。然后分别对着两个线程进行中断操作,可以看出sleepThread抛出InterruptedException后清除标志位,而busyThread就不会清除标志位。
另外,可以通过中断的方式实现线程间的简单交互,因为可以通过isInterrupted()
方法监控某个线程的中断标志位是否清零,针对不同的中断标志位进行不同的处理。
2.5.2 join
join方法也是一种线程间协作的方式
,很多时候,一个线程的输入可能非常依赖于另一个线程的输出。如果在一个线程threadA中执行了threadB.join(),其含义是:当前线程threadA会等待threadB线程终止后,threadA才会继续执行
。
方法名 | 详细注释 | 备注 |
---|---|---|
public final void join() throws InterruptedException | 等待这个线程死亡。 | 如果任何线程中断当前线程,如果抛出InterruptedException异常时,当前线程的中断状态将被清除 |
public final void join(long millis) throws InterruptedException | 等待这个线程死亡的时间最多为millis毫秒。 如果参数为 0,意味着永远等待。 | 如果millis为负数,抛出IllegalArgumentException异常 |
public final void join(long millis, int nanos) throws InterruptedException | 等待最多millis毫秒加上这nanos纳秒。 | 如果millis为负数或者nanos不在0-999999范围抛出IllegalArgumentException异常 |
看个例子:
public class JoinDemo {
public static void main(String[] args) {
Thread previousThread = Thread.currentThread();
for (int i = 1; i <= 10; i++) {
Thread curThread = new JoinThread(previousThread);
curThread.start();
previousThread = curThread;
}
}
static class JoinThread extends Thread {
private Thread thread;
public JoinThread(Thread thread) {
this.thread = thread;
}
@Override
public void run() {
try {
thread.join();
System.out.println(thread.getName() + " terminated.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
测试结果:
在上面的例子中一个创建了10个线程,每个线程都会等待前一个线程结束才会继续运行。可以通俗的理解成接力,前一个线程将接力棒传给下一个线程,然后又传给下一个线程…
2.5.3 sleep
sleep方法为:
public static native void sleep(long millis)
显然sleep是Thread的静态方法,它的作用是:让当前线程按照指定的时间休眠,其休眠时间的精度取决于处理器的计时器和调度器。需要注意的是如果当前线程获得了锁,sleep方法并不会失去锁
。
Thread.sleep方法经常拿来与Object.wait()方法进行比较,sleep和wait两者主要的区别:
- sleep()方法是Thread的静态方法,而wait是Object实例方法;
wait()方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁
。而sleep()方法没有这个限制可以在任何地方使用。wait()方法会释放占有的对象锁,使得该线程进入等待池中
,等待下一次获取资源。而sleep()方法只是会让出CPU并不会释放掉对象锁;- sleep()方法在休眠时间达到后,如果再次获得CPU时间片就会继续执行,而wait()方法必须等待Object.notift/Object.notifyAll通知后,才会离开等待池,并且再次获得CPU时间片才会继续执行。
关于sleep方法的使用,示例:
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("第一个线程的执行时间:"+new Date());
}
}).start();
System.out.println("sleep2秒");
Thread.sleep(2000);
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("第二个线程的执行时间:"+new Date());
}
}).start();
}
结果示例:
可以看出,第2个线程的执行时间是晚于第1个线程2秒的。
2.5.4 yield
yield方法为:
public static native void yield()
yield方法的作用:使当前线程从执行状态(运行状态)变为可执行态(就绪状态)
。
yield方法是一个静态方法,一旦执行,它会是当前线程让出CPU。但是,让出了CPU并不是代表当前线程不再运行了。线程调度器可能忽略此此消息,并且如果在下一次竞争中,又获得了CPU时间片当前线程依然会继续运行。另外,让出的时间片只会分配给大于等于当前线程优先级的线程。
在线程中,用priority来表示优先级,priority的范围从1~10。在构建线程的时候可以通过 setPriority(int) 方法进行设置,默认优先级为5,优先级高的线程相较于优先级低的线程优先获得处理器时间片。
需要注意的是,sleep()和yield()方法,同样都是当前线程会交出处理器资源,而它们不同的是,sleep()交出来的时间片其他线程都可以去竞争,也就是说都有机会获得当前线程让出的时间片。而yield()方法只允许大于等于当前线程优先级的线程,竞争CPU时间片
。
2.6 线程相关的一些问题
2.6.1 interrupt、interrupted和isInterrupted方法的区别
interrupt:用于中断线程。调用该方法的线程的状态为将被置为”中断”状态
。
线程中断仅仅是设置线程的中断状态标识,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出interruptedException 的方法)就是在监视线程的中断状态,一旦线程的中断状态标识被置为“中断状态”,就会抛出中断异常。
interrupted:是静态方法,查看当前中断信号是true还是false并且清除中断信号
。如果一个线程被中断了,第一次调用 interrupted 则返回 true,第二次和后面的就返回 false 了。
isInterrupted:查看当前中断信号是true还是false
。
2.6.2 sleep方法和yield方法有什么区别
- 1、
sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会
; - 2、
线程执行 sleep()方法后转入阻塞(blocked)状态,而执行 yield()方法后转入就绪(ready)状态
; - 3、sleep()方法声明抛出 InterruptedException(其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException),而 yield()方法没有声明任何异常;
- 4、sleep()方法比 yield()方法具有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行;
- 5、建议用TimeUnit的sleep代替Thread的sleep来获得更好的可读性。
- 6、sleep()方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
- 7、sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。在调用sleep()方法的过程中,线程不会释放对象锁。
- 8、当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备,获取对象锁进入运行状态。
2.6.3 线程怎么处理异常
如果线程运行中产生了异常,首先会生成一个异常对象。我们平时throw抛出异常,就是把异常交给JVM处理。JVM首先会去找有没有能够处理该异常的处理者(首先找到当前抛出异常的调用者,如果当前调用者无法处理,则会沿着方法调用栈一路找下去),能够处理的调用者实际就是看方法的catch关键字,JVM会把该异常对象封装到catch入参,允许开发者手动处理异常。
若找不到能够处理的处理者(实际就是没有手动catch异常,比如未受检异常),就会交该线程处理;JVM会调用Thread类的dispatchUncaughtException()方法,该方法调用了getUncaughtExceptionHandler(),uncaughtExceptoin(this,e)来处理了异常,如果当前线程设置了自己的UncaughtExceptionHandler,则使用该handler,调用自己的uncaughtException方法。如果没有,则使用当前线程所在的线程组的Handler的uncaughtExceptoin()方法,如果线程中也没有设置,则直接把异常定向到System.err中,打印异常信息(控制台红色字体输出的异常就是被定向到System.err的异常)。
2.6.4 Thread.sleep(0)的作用是什么
由于Java采用抢占式的线程调度算法,因此可能会出现某条线程常常获取到CPU控制权的情况,为了让某些优先级比较低的线程也能获取到CPU控制权,可以使用Thread.sleep(0)手动触发一次操作系统分配时间片的操作,这也是平衡CPU控制权的一种操作。
2.6.5 一个线程如果出现了运行时异常会怎么样
- 如果这个异常没有被捕获的话,这个线程就停止执行了。
- 另外重要的一点是:如果这个线程持有某个对象的监视器器,那么这个对象监视器器会被立即释放。
2.6.6 终止线程运行的几种情况
- 线程体中调用了yield方法让出了对CPU的占用权利;
- 线程体中调用了sleep方法使线程进入睡眠状态;
- 线程由于IO操作受到阻塞;
- 另外一个更高优先级线程出现,导致当前线程未分配到时间片;
- 在支持时间片的系统中,该线程的时间片用完。
- 使用stop方法强行终止,但是
不推荐
这个方法,因为stop是过期作废的方法。 - 使用interrupt方法中断线程。
2.6.7 如何优雅地设置睡眠时间
JDK1.5之后,引入了一个枚举TimeUnit,对sleep方法提供了很好的封装。
比如要休眠2小时22分55秒899毫秒,两种写法:
Thread.sleep(8575899);
TimeUnit.HOURS.sleep(3);
TimeUnit.MINUTES.sleep(22);
TimeUnit.SECONDS.sleep(55);
TimeUnit.MILLISECONDS.sleep(899);
2.6.8 如何设置上下类类加载器
获取线程上下文类加载器:
public ClassLoader getContextClassLoader()
设置线程类加载器(可以打破Java类加载器的父类委托机制):
public void setContextClassLoader(ClassLoader cl)
2.6.9 如何停止一个正在运行的线程
- 当run方法完成后线程自动终止
- 使用stop方法强行终止
不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。 - 可以使用共享变量的方式
在这种方式中,之所以引入共享变量,是因为该变量可以被多个执行相同任务的。线程用来作为是否中断的信号,通知中断线程的执行。 - 使用interrupt方法终止线程
如果一个线程由于等待某些事件的发生而被阻塞,又该怎样停止该线程呢?这种情况经常会发生,比如当一个线程由于需要等候键盘输入而被阻塞,或者调用Thread.join()方法,或者Thread.sleep()方法,在网络中调用ServerSocket.accept()方法,或者调用了DatagramSocket.receive()方法时,都有可能导致线程阻塞,使线程处于处于不可运行状态时,即使主程序中将该线程的共享变量设置为true,但该线程此时根本无法检查循环标志,当然也就无法立即中断。这里我们给出的建议是,不要使用stop()方法,而是使用Thread提供的interrupt()方法,因为该方法虽然不会中断一个正在运行的线程,但是它可以使一个被阻塞的线程抛出一个中断异常,从而使线程提前结束阻塞状态,退出堵塞代码。
2.6.10 为什么Thread类的sleep()和yield()方法是静态的
Thread类的sleep()和yield()方法将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。
2.6.11 怎么检测一个线程是否拥有锁
在Thread类中有一个静态方法叫holdsLock(Object o),返回true表示:当且仅当当前线程拥有某个具体对象的锁。
2.6.12 线程的调度策略
线程调度器选择优先级最高的线程运行,但是,如果发生以下情况,就会终止线程的运行:
1、线程体中调用了yield方法让出了对CPU的占用权利;
2、线程体中调用了sleep方法使线程进入睡眠状态;
3、线程由于IO操作受到阻塞;
4、另外一个更高优先级线程出现;
5、在支持时间片的系统中,该线程的时间片用完。
2.6.13 线程的调度策略
1)使用top命令查找java命令下cpu占用最高的进程:
例如pid为9595的进程是占用cpu使用率最大的。
2)使用top -H -p 9595
查看当前pid为9595进程下各线程占用cpu情况:
可以看到,pid为10034的线程占用cpu是最高的。
3)将线程的pid由10进制转成16进制:
4)把进程的全部堆栈信息导入到临时文件中:
jstack 9595 > /tmp/a.txt
5)通过vi /tmp/a/txt
查看该文件:
2.6.14 join可以保证线程执行顺序的原理
Thread的join()方法:
public final void join() throws InterruptedException {
join(0);
}
public final synchronized void join(long millis) throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
可以看到,有一个long类型参数的join()方法使用了synchroinzed修饰,说明这个方法同一时刻只能被一个实例或者方法调用。由于,传递的参数为0,所以,程序会进入如下代码逻辑。
if (millis == 0) {
while (isAlive()) {
wait(0);
}
}
首先,在代码中以while循环的方式来判断当前线程是否已经启动处于活跃状态,如果已经启动处于活跃状态,则调用同类中的wait()方法,并传递参数0。继续跟进wait()方法,如下所示。
public final native void wait(long timeout) throws InterruptedException;
wait()方法是一个本地方法,通过JNI的方式调用JDK底层的方法来使线程等待执行完成。
调用线程的wait()方法时,会使主线程处于等待状态,等待子线程执行完成后再次向下执行。也就是说,在ThreadSort02类的main()方法中,调用子线程的join()方法,会阻塞main()方法的执行,当子线程执行完成后,main()方法会继续向下执行,启动第二个子线程,并执行子线程的业务逻辑,以此类推。
2.6.15 stop()方法和interrupt()方法的区别
- 1、stop()方法
stop()方法会真的杀死线程。如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,那其他线程就再也没机会获得ReentrantLock锁, 这样其他线程就再也不能执行ReentrantLock锁锁住的代码逻辑。 - 2、interrupt()方法
interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被interrupt的线程,有两种方式接收通知:一种是异常, 另一种是主动检测。 - 1)通过异常接收通知
当线程A处于WAITING、 TIMED_WAITING状态时, 如果其他线程调用线程A的interrupt()方法,则会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。线程转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法, 我们看这些方法的签名时,发现都会throws InterruptedException这个异常。这个异常的触发条件就是:其他线程调用了该线程的interrupt()方法。
当线程A处于RUNNABLE状态时,并且阻塞在java.nio.channels.InterruptibleChannel上时, 如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;当阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。 - 2)主动检测通知
如果线程处于RUNNABLE状态,并且没有阻塞在某个I/O操作上,例如中断计算基因组序列的线程A,此时就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法, 那么线程A可以通过isInterrupted()方法, 来检测自己是不是被中断了。
2.7 多线程使用示例
使用多线程下载文件,可以简单分为以下几步:
- 1、获取目标文件的大小
在本地留好足量的空间来存储。 - 2、确定要开启几个线程
所开线程的最大数量=(CPU核数+1),本例子中开三个线程。 - 3、 计算平均每个线程需要下载多少个字节的数据
理想情况下多线程下载是按照平均分配原则的,即:单线程下载的字节数等于文件总大小除以开启的线程总条数,当不能整除时,则最后开启的线程将剩余的字节一起下载。 - 4、计算各个线程要下载的字节范围
在下载过程中,各个线程都要明确自己的开始索引(startIndex)和结束索引(endIndex)。 - 5、使用for循环开启子线程进行下载
- 6、获取各个线程的目标文件的开始索引和结束索引的范围
- 7、创建文件,接收下载的流
示例:
package ThreadTest;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;
public class DownloadTest {
private static final String path = "http://down.360safe.com/se/360se9.1.0.426.exe";
public static void main(String[] args) throws Exception {
/*第一步:获取目标文件的大小*/
int totalSize = new URL(path).openConnection().getContentLength();
System.out.println("目标文件的总大小为:"+totalSize+"B");
/*第二步:确定开启几个线程。开启线程的总数=CPU核数+1;例如:CPU核数为4,则最多可开启5条线程*/
int availableProcessors = Runtime.getRuntime().availableProcessors();
System.out.println("CPU核数是:"+availableProcessors);
int threadCount = 3;
/*第三步:计算每个线程要下载多少个字节*/
int blockSize = totalSize/threadCount;
/*每次循环启动一条线程下载*/
for(int threadId=0; threadId<3;threadId++){
/*第四步:计算各个线程要下载的字节范围*/
/*每个线程下载的开始索引*/
int startIndex = threadId * blockSize;
/*每个线程下载的结束索引*/
int endIndex = (threadId+1)* blockSize-1;
/*如果是最后一条线程*/
if(threadId == (threadCount -1)){
endIndex = totalSize -1;
}
/*第五步:启动子线程下载*/
new DownloadThread(threadId,startIndex,endIndex).start();
}
}
private static class DownloadThread extends Thread{
private int threadId;
private int startIndex;
private int endIndex;
public DownloadThread(int threadId, int startIndex, int endIndex) {
super();
this.threadId = threadId;
this.startIndex = startIndex;
this.endIndex = endIndex;
}
@Override
public void run(){
System.out.println("第"+threadId+"条线程,下载索引:"+startIndex+"~"+endIndex);
/*每条线程要去找服务器拿取一段数据*/
try {
URL url = new URL(path);
HttpURLConnection connection = (HttpURLConnection)url.openConnection();
/*设置连接超时时间*/
connection.setConnectTimeout(5000);
/*第六步:获取目标文件的[startIndex,endIndex]范围*/
connection.setRequestProperty("range", "bytes="+startIndex+"-"+endIndex);
connection.connect();
/*获取响应码,当服务器返回的是文件的一部分时,响应码不是200,而是206*/
int responseCode = connection.getResponseCode();
if (responseCode == 206) {
//拿到目标段的数据
InputStream is = connection.getInputStream();
/*第七步:创建一个RandomAccessFile对象,将返回的字节流写到文件指定的范围*/
String fileName = getFileName(path);
/*创建一个可读写的文件,即把文件下载到D盘*/
RandomAccessFile raf = new RandomAccessFile("d:/"+fileName, "rw");
/*注意:让raf写字节流之前,需要移动raf到指定的位置开始写*/
raf.seek(startIndex);
/*将字节流数据写到文件中*/
byte[] buffer = new byte[1024];
int len = 0;
while((len=is.read(buffer))!=-1){
raf.write(buffer, 0, len);
}
is.close();
raf.close();
System.out.println("第 "+ threadId +"条线程下载完成 !");
} else {
System.out.println("下载失败,响应码是:"+responseCode);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
/*获取文件的名称*/
private static String getFileName(String path){
int index = path.lastIndexOf("/");
String fileName = path.substring(index+1);
return fileName ;
}
}
测试结果:
目标文件的总大小为:48695168B
CPU核数是:4
第0条线程,下载索引:0~16231721
第1条线程,下载索引:16231722~32463443
第2条线程,下载索引:32463444~48695167
第 1条线程下载完成 !
第 0条线程下载完成 !
第 2条线程下载完成 !
下载文件:
三、线程的活性故障
线程活性故障是由于资源稀缺性或程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或者线程虽然处于RUNNABLE状态,但是其要执行的任务却一直无法进展的故障现象。
3.1 死锁
死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去
。
如图所示,线程A持有资源2,线程B持有资源1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态:
3.1.1 死锁的产生条件
当线程产生死锁时,这些线程及相关的资源将满足如下全部条件:
- 1、互斥
一个资源只能被一个线程(进程)占用
,直到被该线程(进程)释放。 - 2、请求与保持(不主动释放)条件
一个线程(进程)因请求被占用资源(锁)而发生阻塞时,对已获得的资源保持不放
。 - 3、不剥夺(不能被强占)条件
线程(进程)已获得的资源
,在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源
。 - 4、循环等待(互相等待)条件
当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞
。
用一句话该概括:
两个或多个线程持有并且不释放独有的锁,并且还需要竞争别的线程所持有的锁,导致这些线程都一直阻塞下去。
这些条件是死锁产生的必要条件而非充分条件,也就是说只要产生了死锁,那么上面的这些条件一定同时成立
,但是上述条件即便同时成立也不一定产生死锁。
如果把锁看作一种资源,这种资源正好符合“资源互斥”和“资源不可抢夺”的要求。那么,可能产生死锁的特征就是在持有一个锁的情况下去申请另外一个锁,通常是锁的嵌套,示例:
//内部锁
public void deadLockMethod1(){
synchronized(lockA){
//...
synchronized(lockB){
//...
}
}
}
//显式锁
public void deadLockMethod2(){
lockA.lock();
try{
//...
lockB.lock();
try{
//...
}finally{
lockB.unlock();
}
}finally{
lockA.unlock();
}
}
示例:
private static Object lockObject1 = new Object();
private static Object lockObject2 = new Object();
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
try {
test1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
test2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
public static void test1() throws InterruptedException{
synchronized (lockObject1) {
System.out.println(Thread.currentThread().getName()
+"获取到lockObject1,正在获取lockObject2");
Thread.sleep(1000);
synchronized (lockObject2) {
System.out.println(Thread.currentThread().getName()
+"获取到lockObject2");
}
}
}
public static void test2() throws InterruptedException{
synchronized (lockObject2) {
System.out.println(Thread.currentThread().getName()
+"获取到lockObject2,正在获取lockObject1");
Thread.sleep(1000);
synchronized (lockObject1) {
System.out.println(Thread.currentThread().getName()
+"获取到lockObject1");
}
}
}
以下结果表明已经出现了死锁:
Thread-1获取到lockObject2,正在获取lockObject1
Thread-0获取到lockObject1,正在获取lockObject2
3.1.2 死锁的规避
由上文可知,要产生死锁需要同时满足四个条件,所以,只要打破其中一个条件就可以避免死锁的产生。常用的规避方法有如下几种:
- 1、粗锁法
用一个粒度较粗的锁
替代原来的多个粒度较细的锁,这样涉及的线程都只需要申请一个锁从而避免了死锁。粗锁法的缺点是它明显降低了并发性并可能导致资源浪费。 - 2、锁排序法
相关线程使用全局统一的顺序申请锁。假设有多个线程需要申请锁(资源),那么只需要让这些线程依照一个全局(相对于使用这种资源的所有线程而言)统一的顺序去申请这些资源,就可以消除“循环等待资源”这个条件,从而规避死锁。一般,可以使用对象的hashcode作为资源的排序依据。 - 3、使用ReentrantLock.tryLock(long timeout, TimeUnit unit) 申请锁
ReentrantLock.tryLock(long timeout, TimeUnit unit) 允许为申请锁这个操作加上一个超时时间。在超时事件内,如果相应的锁申请成功,该方法返回true。如果在tryLock执行的那一刻相应的锁正在被其他线程持有,那么该方法会使当前线程暂停,直到这个锁申请成功(此时该方法返回true)或者等待时间超过指定的超时时间(此时该问题返回false)。因此,使用ReentrantLock.tryLock(long timeout, TimeUnit unit) 来申请锁可以避免一个线程无限制地等待另外一个线程持有的资源
,从而最终能够消除死锁产生的必要条件中的“占用并等待资源”。示例:
boolean locked = false;
try {
locked = lock.tryLock(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if(locked) lock.unlock();
}
- 4、使用开放调用----在调用外部方法时不加锁
开放调用是一个方法在调用方法(包括其他类的方法以及当前类的可覆盖方法)的时候不持有任何锁。显然,开放调用能够消除死锁产生的必要条件中的“持有并等待资源”。 - 5、使用锁的替代品
使用一些锁的替代品
(无状态对象、线程特有对象以及volatile关键字等
),在条件允许的情况下,使用这些替代品在保障线程安全的前提下不仅能够避免锁的开销,还能够直接避免死锁。
3.2 线程饥饿和活锁
线程饥饿是指一直无法获得其所需的资源而导致任务一直无法进展的一种活性故障。
线程饥饿的一个典型例子是在争用的情况下使用非公平模式的读写锁。此种情况下,可能会导致某些线程一直无法获取其所需的资源(锁),即导致线程饥饿。
把锁看作一种资源的话,其实死锁也是一种线程饥饿。死锁的结果是故障线程都无法获得其所需的全部锁中的一个锁,从而使其任务一直无法进展,这相当于线程无法获得其所需的全部资源(锁)而使得其任务一直无法进展,即产生了线程饥饿。由于线程饥饿的产生条件是一个(或多个)线程始终无法获得其所需的资源,显然这个条件的满足并不意味着死锁的必要条件(而不是充分条件)的满足,因此线程饥饿并不会导致死锁。
Java中导致饥饿的原因:
- 高优先级线程抢占了所有的低优先级线程的 CPU 时间。
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
- 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。
线程饥饿涉及的线程,其生命周期不一定就是WAITING或BLOCKED状态,其状态也可能是RUNNING(说明涉及的线程一直在申请宁其所需的资源),这时饥饿就会演变成活锁。
活锁指线程一直处于运行状态,但是其任务却一直无法进展的一种活性故障。
3.3 死锁与活锁的区别,死锁与饥饿的区别
-
死锁
是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。 -
活锁
任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,却一直获得不了锁。 -
饥饿
一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。 -
1、活锁与死锁的区别
活锁和死锁的区别在于:处于活锁的实体是在不断的改变状态,这就是所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能
。活锁可以认为是一种特殊的饥饿。 -
2、死锁活锁与饥饿的区别
进程会处于饥饿状态是因为持续地有其它优先级更高的进程请求相同的资源。不像死锁或者活锁,饥饿能够被解开。例如,当其它高优先级的进程都终止时并且没有更高优先级的进程强占资源。
转载:https://blog.csdn.net/m0_37741420/article/details/120686803