学习日记(多线程)
线程(thread):是一个程序内部的一条执行路径。启动程序后,main 方法就是一条单独的执行路径。
多线程:指从软硬件上实现多条执行流程的技术。
一、多线程的创建
多线程的创建有三种方式:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口并结合 FutureTask 类。
1. 方式一:继承 Thread 类
步骤:
- 定义一个子类 MyThread 继承线程类 Thread,重写 run 方法;
- 创建子类对象(线程对象);
- 调用线程对象的 start 方法启动线程(启动后还是执行 run 方法的)。
优点:编码简单。
缺点:存在单继承的局限性,线程类已经继承了 Thread 类,无法继承其他类,不利于扩展。
注意:
- 线程对象只有调用 start 方法启动线程,而不是 run 方法。因为,直接调用 run 方法会当成普通方法执行,此时相当于还是单线程执行。只有调用 start 方法才是启动一个新的线程执行。
- 应该要把子线程放在主线程之前。否则,如果主线程在前,会将主线程一直跑完,相当于还是一个单线程。
2. 方式二:实现 Runnable 接口
步骤:
- 定义一个线程任务类 MyRunnable 实现 Runnable 接口,重写 run 方法;
- 创建一个任务对象 target;
- 把任务对象 target 交给 Thread 处理;
- 调用线程对象的 start 方法启动线程。
优点:线程任务类只是实现了 Runnable 接口,可以继续继承类和实现接口。
缺点:如果线程有执行结果是不能直接返回的。
Thread 构造器 | 说明 |
---|---|
public Thread(Runnable target) | 封装 Runnable 对象成为线程对象(把任务对象 target 交给 Thread 处理) |
另一种写法:将上面步骤中的前两步换为:创建 Runnable 接口的匿名内部类对象。
上面继续简化为
注意:方式一和方式二都存在一个问题:重写的 run 方法不能直接返回结果,不适合需要返回线程执行结果的业务场景。
3. 方式三:实现 Callable 接口并结合 FutureTask 类
步骤:
- 定义类实现 Callable 接口,重写 call 方法,封装要做的事情;
- 用 FutureTask 把 Callable 对象封装成线程任务对象;
- 把线程任务对象交给 Thread 处理;
- 调用 Thread 对象的 start 方法启动线程,执行任务;
- 线程执行完毕后,通过 FutureTask 的 get 方法去获取任务执行后的结果。
优点:线程任务类 Callable 只是实现接口,可以继续继承类和实现接口,扩展性强;可以在线程执行完毕后获取线程执行的结果。
缺点:编码复杂一点(对比来看)。
构造器或方法 | 说明 | |
---|---|---|
构造器 | public FutureTask(Callable callable) | 用构造器把 Callable 对象封装成线程任务对象 |
方法 | public V get() throws InterruptedException, ExecutionException | 获取任务执行后的结果 |
注意:FutureTask 的作用有两个:
- 是 Runnable 接口的对象(实现了 Runnable 的接口),可以把 Callable 对象封装后交给线程对象;
- 在线程执行完毕后,通过调用 FutureTask 对象的 get 方法去获取任务执行后的结果。
二、Thread 的常用方法
方法名 | 说明 |
---|---|
public final void setName(String name) | 将此线程的名称修改为指定的名称,也可以在创建线程时通过构造器修改 |
public final String getName() | 获取当前线程的名称,默认的线程名称为:Thread-索引 |
public static native Thread currentThread() | 获取当前的线程对象,static 方法,返回值类型为 Thread(线程对象) |
注意:在实际开发中,一般不为线程改名字。
方法名 | 说明 |
---|---|
public static native void sleep(long millis) throws InterruptedException | 让线程休眠指定的时间,单位为毫秒 |
三、线程安全
线程安全问题:多个线程同时操作同一共享资源时,可能会出现的业务安全问题。
线程安全问题出现的原因:
- 存在多线程并发;
- 同时访问共享资源;
- 存在修改共享资源。
需求:小明和小红两个人,有一个共同的账户,余额为 10 万元,模拟两人同时去取 10 万元的情况。
分析:
- 定义一个账户类 Account,创建一个账户对象 acc 代表两个人的共同账户;
- 定义一个线程类 DrawThread,线程类可以操作账户对象 acc;
- 创建两个线程对象,传入同一个账户对象 acc;
- 启动线程,去同一个账户对象中取钱 10 万元。
四、线程同步
线程同步是为了解决线程安全问题。
线程同步的核心思想:加锁,让多个线程实现先后依次访问共享资源,这样就解决了安全问题。
加锁的方式有三种:同步代码块、同步方法、Lock 锁。
1. 方式一:同步代码块
作用:把出现线程安全问题的核心代码使用 synchronized
给上锁。
原理:每次只能一个线程进入,执行完毕后自动解锁,其他线程才可以进来执行。
同步代码块快捷键:Ctrl + Alt + t
。
锁对象要求:理论上锁对象只要对于当前同时执行的线程来说是同一个对象即可。但是锁对象用任意唯一的对象不好,原因是会影响其他无关线程的执行。因此,锁对象在规范上,建议使用共享资源作为锁对象。
- 对于实例方法建议使用
this
作为锁对象; - 对于静态方法建议使用字节码(
类名.class
)对象作为锁对象。
2. 方式二:同步方法
作用:把出现线程安全问题的核心方法使用 synchronized
修饰。
原理:每次只能一个线程进入,执行完毕后自动解锁,其他线程才可以进来执行。
同步方法的底层原理:
- 同步方法其实底层也是有隐式锁对象的,只是锁的范围是整个方法代码。
- 如果方法是实例方法:同步方法默认使用
this
作为锁对象,但是代码要高度面向对象。 - 如果方法是静态方法:同步方法默认使用字节码(
类名.class
)对象作为锁对象。
注意:同步代码块锁的范围更小,同步方法锁的范围更大。但是实际中,同步方法还是用得更多一点。
3. 方式三:Lock 锁
Lock 锁有比使用 synchronized
方法和语句更广泛的锁定操作。
Lock 锁是接口,不能实例化,这里采用它的实现类 ReentrantLock
来构建 Lock 锁对象。
在账户类中用实现类构建 Lock 锁对象的方法:private final Lock lock = new ReentrantLock();
。
方法名 | 说明 |
---|---|
void lock() | 上锁 |
void unlock() | 解锁 |
注意:Lock 锁还有其他强大功能,之后遇到学习。
五、线程通信
概述:线程通信就是线程间相互发送数据,线程通信通常共享一个数据的方式实现。
线程通信常见模型:生产者与消费者模型:生产者线程负责生产数据,消费者线程负责消费数据。
要求:生产者线程生产完数据后,唤醒消费者,然后等待自己;消费者消费完该数据后,唤醒生产者,然后等待自己。
线程通信的前提:线程通信通常是在多个线程操作同一共享资源时需要进行通信,且要保证线程安全。
线程通信的三个常见方法:Object
类的等待和唤醒方法:
方法名 | 说明 |
---|---|
public final void wait() | 让当前线程等待并释放所占的锁,直到另一个线程调用 notify 或 notifyAll 方法 |
public final native void notify() | 唤醒正在等待的单个线程 |
public final native void notifyAll() | 唤醒正在等待的所有线程 |
注意:
- 这些方法应该使用当前同步锁对象进行调用,因为只有锁对象才会知道谁在调用我,我要等待谁。
- 一般先唤醒别的线程,然后再等待自己。
- 线程通信通常共享一个数据的方式实现,如判断这个数据是否有,然后决定是否唤醒或等待。
- 锁可以跨方法,如有两个实例方法,他们都用
synchronized
修饰,这样都默认this
为同步锁对象,当多个线程分别调用这两个方法时,只能有一个线程的一个方法被执行,因为这两个方法共用一个锁。
六、线程池
线程池:是一个可以复用线程的技术。
优点:可以复用线程,提高系统的性能。
1. 线程池实现的 API 以及参数说明
线程池的代表:ExecutorService
接口。
得到线程池的两种方式:
- 方式一(重点):使用接口
ExecutorService
的实现类ThreadPoolExecutor
来创建一个线程池对象。 - 方式二:使用线程池的工具类
Executors
来调用方法返回不同特点的线程池对象。
实现类 ThreadPoolExecutor
的构造器参数说明:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- 参数一
corePoolSize
:指定线程池的核心线程数量,不能小于 0; - 参数二
maximumPoolSize
:指定线程池可支持的最大线程数(核心线程 + 临时线程),最大数量 >= 核心线程数; - 参数三
keepAliveTime
:指定临时线程的最大存活时间,也就是临时线程的任务执行完后,还能最多空闲的时间,不能小于 0; - 参数四
unit
:指定参数三存活时间的单位(秒、分、时、天),表示时间单位; - 参数五
workQueue
:指定任务队列,也就是除了所有线程正在处理的任务外,还在等待被处理的任务,不能为 null; - 参数六
threadFactory
:指定用哪个线程工厂创建线程,不能为 null; - 参数七
handler
:指定线程忙,任务满的时候,新任务来了怎么办,不能为 null;
问题一:临时线程什么时候创建?
答:新任务提交时,发现核心线程都在忙,任务队列也满了,并且还可以创建临时线程时,此时才会创建临时线程。
问题二:什么时候会开始拒绝任务?
答:核心线程和临时线程都在忙,任务队列也满了,新的任务过来的时候才会开始拒绝任务。
2. 线程池处理 Runnable 任务
步骤:
- 创建线程池;
- 创建任务对象,调用线程池方法,处理任务。
实现类 ThreadPoolExecutor
通过构造器创建线程池对象示例:
ExecutorService pool = new ThreadPoolExecutor(3, 5, 7, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(5), Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
线程池 ExecutorService
的常用方法:
方法名 | 说明 |
---|---|
void execute(Runnable command) | 执行任务/命令,没有返回值,一般用来执行 Runnable 任务 |
void shutdown() | 等任务执行完毕后关闭线程池 |
List shutdownNow() | 立刻关闭线程池,停止正在执行的任务,并返回队列中未执行的任务 |
注意:
- 在执行完全部任务后,线程池不会主动关闭,要想关闭线程池,需要用 shutdown 方法在所有任务执行完后关闭。
- shutdownNow 表示即使任务没有完成,也立即关闭线程池,这样会丢失任务,因此,在开发中一般不会使用。
- 构造器的第七个参数表示新任务拒绝策略,
ThreadPoolExecutor.AbortPolicy()
是默认的策略,表示丢弃任务并抛出RejectedExecutionException
异常,此外,还有其他策略。- 线程池处理 Runnable 任务和在第一部分的方式二类似,只不过把创建一个线程的步骤换为了线程池。
3. 线程池处理 Callable 任务
步骤:
- 创建线程池;
- 创建任务对象,调用线程池方法,处理任务。
方法名 | 说明 | |
---|---|---|
ExecutorService 接口的方法 | Future submit(Callable task) | 执行 Callable 任务,返回未来任务对象 |
Future 接口的方法 | V get() | 通过未来任务对象调用 get 方法,获取线程执行结果 |
注意:线程池处理 Callable 任务和在第一部分的方式三类似,只不过把创建一个线程的步骤换为了线程池。
- 第一部分方式三中:由于线程对象不能直接执行 Callable 任务,所以通过 FutureTask 类可以把 Callable 对象封装成任务对象,并调用 get 方法去获取任务执行后的结果;
- 该部分中:通过线程池的 submit 方法可以直接执行 Callable 任务并返回未来任务对象,再调用 get 方法可以获取任务执行后的结果。
4. Executors 工具类实现线程池
Executors:是线程池的工具类,通过调用静态方法返回不同类型的线程池对象。
Executors 工具类底层其实也是基于 ExecutorService
接口的实现类 ThreadPoolExecutor
来创建线程池对象的。
在大型并发系统环境中使用 Executors 工具类,如果不注意可能会出现系统风险。
方法名 | 说明 | 存在的问题 |
---|---|---|
public static ExecutorService newCachedThreadPool() | 线程数量随着任务的增加而增加,如果线程任务执行完毕且空闲了一段时间则会被回收掉 | 创建的线程数量最大上限为 Integer.MAX_VALUE,线程数可能会随着任务 1:1 增长,也可能出现内存溢出错误 |
public static ExecutorService newFixedThreadPool(int nThreads) | 创建固定线程数量的线程池,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程替代它 | 允许请求的任务队列长度是 Integer.MAX_VALUE,可能出现内存溢出错误 |
public static ExecutorService newSingleThreadExecutor() | 创建只有一个线程的线程池,如果该线程因为执行异常而结束,那么线程池会补充一个新线程替代它 | 同 2 |
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) | 创建一个线程池,可以实现在给定的延迟后运行任务,或者定期执行任务(适合做定时器) | 同 1 |
注意:Executors 工具类不适合在大型并发系统环境中使用,建议使用方式一创建线程池对象,即,使用接口
ExecutorService
的实现类ThreadPoolExecutor
来创建一个线程池对象,可以明确线程池的运行规则,规避资源耗尽的风险。
七、定时器
定时器是一种控制任务延时调用或者周期调用的技术,如闹钟、定时邮件的发送。
定时器的实现方式有两个:Timer、ScheduledExecutorService(更好)。
1. 方式一:Timer
定时器本身就是一个单线程。
构造器或方法名 | 说明 | |
---|---|---|
构造器 | public Timer() | 创建 Timer 定时器对象 |
方法 | public void schedule(TimerTask task, long delay, long period) | 开启一个定时器,处理 TimerTask 任务 |
注意:schedule 方法的第二个参数是启动延时,第三个参数是周期延时。
Timer 定时器的特点和存在的问题:
- Timer 是单线程,处理多个任务按照顺序执行,存在实际延时与设置定时器的延时有出入的问题。
- 可能会因为其中的某个任务异常使得 Timer 线程死掉,从而影响后续任务执行。
总结:要想办法解决这两个问题,可以创建多个 Timer 对象,就可以用线程池来处理。
2. 方式二:ScheduledExecutorService
ScheduledExecutorService 弥补了 Timer 的缺陷。
优点:基于线程池,某个任务的执行情况不会影响其他定时任务的执行。
类或接口 | 方法名 | 说明 |
---|---|---|
Executors 类 | public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) | 得到线程池对象 |
ScheduledExecutorService 接口 | public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) | 通过线程池对象调用,周期完成任务 |
注意:scheduleAtFixedRate 方法中第一个参数为定时器任务对象、第二个参数是启动延时、第三个参数是周期延时、第四个参数是时间单位。
注意:
- 得到线程池对象时,参数是核心线程的数量,最好设置为大于任务的数量。
- 当任务中有 sleep 延时,则两次任务执行的间隔时间为 sleep 延时时间与定时器周期时间的最大值。
八、线程并发、并行
正在运行的程序(软件)就是一个独立的进程,线程是属于进程的,多个线程其实是并发与并行同时进行的。
- 并发
- CPU 同时处理线程的数量有限;
- CPU 会轮询为系统的每个线程服务,由于 CPU 切换的速度很快,给我们的感觉是这些线程在同时执行,这就是并发。
- 并行
- 在同一个时刻上,同时有多个线程在被 CPU 处理并执行。
九、线程的生命周期
线程的状态:也就是线程从生到死的过程,以及中间经历的各种状态和状态转换。
Java 线程的状态有 6 中,定义在了 Thread 类的内部枚举类中。
线程状态 | 描述 |
---|---|
NEW(新建状态) | 线程刚被创建,但是未启动 |
RUNNABLE(可运行/就绪状态) | 线程已经调用了 start 方法等待 CPU 调度 |
BLOCKED(锁阻塞状态) | 线程在执行的时候未竞争到锁对象,则该线程进入 BLOCKED 状态 |
WAITING(无限等待状态) | 一个线程进入 WAITING 状态,另一个线程调用 notify 或 notifyAll 方法才能唤醒 |
TIMED_WAITING(计时等待状态) | 同 WAITING 状态,有几个方法有超时参数,调用线程进入 TIMED_WAITING 状态 |
TERMINATED(被终止/结束状态) | 因为 run 方法正常退出而死亡,或者因为没有捕获异常终止了 run 方法而死亡 |
注意:带有超时参数的常用方法有:
Thread.sleep(3000)
、Object.wait()
、Object.wait(3000)
。
sleep 和 wait 的区别:
- sleep 是线程中的方法,而 wait 是 Object 中的方法;
- sleep 如果在方法内,不会释放锁,休眠后别的线程不能执行该方法,直到休眠时间结束,而 wait 休眠后,无论是否指定休眠时间,都会释放锁,并且加入到等待队列中,别的线程就可以继续竞争锁,执行该方法;
- sleep 方法不依赖于同步关键字
synchronized
,而 wait 需要依赖同步关键字synchronized
; - sleep 不需要被唤醒,休眠之后就继续运行,而 wait 方法如果不指定时间需要被其他线程唤醒。
注意:
- 接口可以直接创建它的匿名内部类对象,然后重写方法。
- 对于静态方法建议使用字节码(
类名.class
)对象作为锁对象。
- 类
ReentrantLock
实现了Lock
接口。
-
线程池调用
execute
或submit
方法就是在启动线程,而之前启动单个线程,则需要线程对象调用start
方法。 -
Timer 定时器本身就是一个单线程,前提是只创建了一个定时器对象,如果创建了多个定时器对象,则它们单独执行任务,互相不影响。