JAVAEE多线程 ---- 三
1.wait 和 notify
线程的调度是无序的 , 随机的 . 但是 , 也有一定的需求场景 , 希望线程有序执行
join 是一种控制顺序的方式
wait 就是让某个线程先暂停下来等一等 , 发现条件不满足 , 就先阻塞等待
notify 就是把该线程唤醒 , 能够继续执行 , 其他线程构造了一个成熟的条件 , 就可以唤醒
wait 和 notify 都是 Object 的方法
1.1wait
wait 做的事情:
- 解锁
- 阻塞等待
- 当收到通知的时候 , 就唤醒 , 同时尝试重新获取锁
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("wait 之前");
synchronized (object) {
object.wait();
}
System.out.println("wait 之后");
}
wait 结束等待的条件:
- 其他线程调用该对象的 notify 方法.
- wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
- 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
1.2 notify
notify 也是要放到 synchronized 中使用的
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
try {
System.out.println(" wait 开始");
synchronized (locker) {
locker.wait();
}
System.out.println("wait 结束");
}catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("notify 开始");
locker.notify();
System.out.println("notify 结束");
}
});
t2.start();
}
notify 也是要放到 synchronized 中使用的 .
必须要先执行 wait , 然后 notify , 此时才有效果 . 如果现在还没有 wait , 就 notify , 相当于 , 打空了 , 此时 wait 无法唤醒 , 代码不会出现其他异常
wait 和 sleep 的对比
- wait 和 sleep 都可以进行提前唤醒
- wait 解决的是线程之间的顺序控制
- sleep 单纯是让当前线程休眠一会
- wait 要搭配锁使用 , sleep 不需要
2.单例模式
单例模式 , 是一种经典的设计模式 (在校招阶段 , 主要考察两个模式 : 单例模式 , 工厂模式 )
单例 : 单个实例 , 一个程序中 , 某个类 , 只创建出一个实例 ( 一个对象 )
Java中实现单例模式有很多种写法 :这里只说两种
- 饿汉模式 ( 急迫 ) : 把文件所有内容都读取到内存中 , 并显示
- 懒汉模式 ( 从容 ) : 只把文件读一小部分 , 把当前屏幕填充上 , 如果用户翻页了 , 在读其他文件内容 , 如果不翻页 , 就省下了
1.饿汉模式
类加载的同时 , 创建实例
被 static 修饰 , 该属性是类的属性 , (类对象上 ) , JVM 中 , 每个类的类对象只有唯一一份 , 类对象里的这个成员自然也是唯一一份了
class Singleton {
// 唯一实例的本体
private static Singleton instance = new Singleton();
//获取到实例的方法
public static Singleton getInstance() {
return instance;
}
//禁止外部 new 实例
private Singleton() { }
}
此处 , 在类内部把 实例 创建好 , 同时禁止外部重新创建实例, 此时 , 就可以保证单例的特性了
2.懒汉模式
类加载的时候不创建实例 , 第一次使用的时候才创建实例
//通过懒汉模式实现一个单例模式
class SingletonLazy {
//volatile 禁止指令重排序
volatile private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
// 这个条件 , 判定是否要加锁 , 如果对象已经有了 , 就不必加锁了 , 此时本身就是安全的
if (instance == null) {
synchronized (SingletonLazy.class) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() { }
}
理解双重 if 判定 / volatile:
- 加锁/ 解锁 是一件开销比较高的事情 , 而懒汉模式的线程不安全只是发生在首次创建实例的时候 , 因此后续使用的时候, 不必再进行加锁了
- 外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了
- 同时为了避免 “内存可见性” 导致读取的 Instance 出现偏差 , 于是补上 volatile
- 当多线程首次调用 getInstance , 大家可能都发现 instance 为 null , 于是又继续往下执行来竞争锁 , 其中竞争成功的线程 , 再完成创建实例的操作
- 当这个实例创建完了之后, 其他竞争到锁的线程就被里层 if 挡住了 , 也就不会继续创建其他实例
小结: 单例模式 , 线程安全问题
- 饿汉模式 , 天然就是安全的 , 只是读操作
- 懒汉模式 , 不安全的 , 有读也有写
- 加锁 , 把 if 和 new 变成原子操作
2. 双重 if , 减少不必要的加锁操作
3. 使用 volatile 禁止 指令重排序 , 保证后续线程肯定能拿到的是完整对象
- 加锁 , 把 if 和 new 变成原子操作
3.阻塞队列
阻塞队列 : 带有阻塞特性 也遵守 "先进先出"原则
- 如果队列空 , 尝试出队列 , 就会阻塞等待 . 等待到队列不空为止
- 如果队列满 , 尝试入队列 , 也会阻塞等待 , 等待到队列不满为止
public class ThreadDemo19 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// 阻塞队列核心方法, 主要有两个.
// 1. put 入队列
queue.put("hello1");
queue.put("hello2");
queue.put("hello3");
queue.put("hello4");
queue.put("hello5");
// 2. take 出队列
String result = null;
result = queue.take();
System.out.println(result);
result = queue.take();
System.out.println(result);
result = queue.take();
System.out.println(result);
result = queue.take();
System.out.println(result);
result = queue.take();
System.out.println(result);
result = queue.take();
System.out.println(result);
}
}
生产者消费者模型
生产者消费者模式 就是通过 一个容器来解决生产者和消费者的强耦合问题
生产者和消费者彼此之间不直接通讯 , 而是通过阻塞队列来进行通讯 , 所以生产者生产完数据之后不用等待消费者处理 , 直接扔给阻塞队列 , 消费者不找生产者要数据 , 而是直接从阻塞队列里取
1.可以让上下游模块之间 , 进行更好的 “解耦合”
2.阻塞队列就相当于一个缓冲区 , 平衡了生产者和消费者的处理能力
标准库中的阻塞队列 :
- BlockingQueue 是一个接口 . 真正实现的类是LinkedBlockingQueue
- put 方法用于阻塞队列的入队列 , take 用于阻塞队列的出队列
- BlockingQueue 也有 offer ,poll,peek等方法 , 但是这些方法不带有阻塞特性
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
//入队列
queue.put("abc");
//出队列 , 如果没有 put 直接 take , 就会阻塞
String elem = queue.take();
生产者消费者模型
public static void main(String[] args) {
BlockingDeque<Integer> blockingDeque = new LinkedBlockingDeque<>();
Thread t1 = new Thread(() -> {
while (true) {
try {
int value = blockingDeque.take();
System.out.println("消费元素 :" + value);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
Thread t2 = new Thread(() -> {
int value = 0;
while (true) {
try {
System.out.println("生产元素:" + value);
blockingDeque.put(value);
value ++;
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t2.start();
//上述代码中 , 让生产者 , 每隔 1s 生产一个元素
//让消费者则直接消费 , 不受限制
}
阻塞队列实现
实现一个阻塞队列 , 需要三部:
- 先实现一个普通队列
- 加上线程安全
- 加上阻塞功能
- 通过 " 循环队列 "的方式实现
- 使用 synchroized 进行加锁控制
- put 插入元素的时候 , 判定如果队列满了 , 就进行 wait , (注意:要在循环中进行 wait ,被唤醒时不一定队列就不满了 , 因为同时可能是唤醒了多个线程)
- take 取出元素的时候 , 判定如果队列为空 , 就进行 wait (也是循环 wait)
class MyBlockingQueue {
private int[] items = new int[1000];
//约定 [head , tail ) 队列 的有效元素
volatile private int head = 0;
volatile private int tail = 0;
volatile private int size = 0;
//入队列
synchronized public void put(int elem) throws InterruptedException {
while (size == items.length) {
//队列满了 , 插入失败
//return;
this.wait();
}
//把新元素放到 tail 所在位置上
items[tail] = elem;
tail++;
//万一 tail 达到末尾 , 就需要让 tail 从头再来
if (tail == items.length) {
tail = 0;
}
size++;
this.notify();
}
//出队列
synchronized public Integer take() throws InterruptedException {
while(size == 0) {
//return null;
this.wait();
}
int value = items[head];
head++;
if (head == items.length) {
head = 0;
}
size--;
this.notify();
return value;
}
}
// 注意 上述两处代码的 wait 不可能同时阻塞 , 一个独立不可能即是空 又是满
public class ThreadDemo21 {
public static void main(String[] args) {
public static void main(String[] args) {
MyBlockingQueue queue = new MyBlockingQueue();
//消费者
Thread t1 = new Thread(() -> {
while (true) {
int value = 0;
try {
value = queue.take();
System.out.println("消费 :" + value);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//生产者
Thread t2 = new Thread(() -> {
int value = 0;
while (true) {
try {
System.out.println("生产 : " + value);
queue.put(value);
Thread.sleep(1000);
value++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
}
}
很有可能在别的代码里暗中 interrupt , 把 wait 给提前唤醒了 , 明明条件还没满足 (队列非空) ,但是 wait 唤醒之后就不继续往下走了 .
更稳妥的做法 , 是在 wait 唤醒之后 , 再判定一次条件
wait 之前 , 发现条件不满足 , 开始 wait ; 然后等到 wait 被唤醒了之后 , 在确认一下条件是不是满足 , 如果不满足 , 还可以继续 wait
解决: 将上述代码的 if 改成 while
4.定时器
设定一个时间 , 当时间到 , 就可以执行一个指定的代码
标准库中的定时器:
- 标准库中提供了一个 Timer 类 , Timer 类的核心方法为 schedule
- schedule 包含两个参数 , 第一个参数指定即将要执行的任务代码 , 第二个参数指定多长时间之后执行 (单位为毫秒)
Timer timer = new Timer():
timer.schedule(new TimerTask () {
@Override
public void run() {
System.out.println("hello");
}
},3000);
实现一个定时器
定时器 , 内部管理的不仅仅是一个任务 , 可以管理很多个任务
虽然任务可能会有很多 , 他们触发的时间是不同的 , 只需要有一个/一组 工作线程, 每次都找到这个任务中 , 最先到达时间的任务 , 一个线程 , 先执行最早的任务 , 做完了之后再执行第二早的 ,时间到了就执行, 没到就等等
定时器的构成:
-
一个带优先级的阻塞队列
为什么要优先级
因为阻塞队列中的任务都有各自执行时刻(delay) . 最先执行的任务一定是delay 最小的 , 使用带优先级的队列就可以高效的把这个 delay 最小的任务找出来
-
队列中的每个元素是一个 Task 对象
-
Task 中带有一个时间属性 ,
-
同时又一个 woker 线程一直扫描队首元素 , 看队首元素是否需要执行
完整代码:
// 表示一个任务
class MyTask implements Comparable<MyTask>{
public Runnable runnable;
//为了方便后续判定 , 使用绝对的时间戳
public long time;
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
// 取当前时刻的时间戳 + delay ,作为该任务实际执行的时间戳
this.time = System.currentTimeMillis() + delay;
}
@Override
public int compareTo(MyTask o) {
//这样的写法意味着每次取出来的是时间最小的元素 ,
//
return (int)(this.time - o.time);
}
}
class MyTimer {
//创建一个锁对象
private Object locker = new Object();
//这个结构 , 带有优先级的阻塞队列 , 核心数据结构
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处的 delay 是一个形如 3000 这样子的数字 (多长时间之后 , 执行该任务)
public void schedule(Runnable runnable, long delay) {
// 根据参数 , 构造 MyTask , 插入队列即可
MyTask myTask = new MyTask(runnable, delay);
queue.put(myTask);
synchronized (locker) {
locker.notify();
}
}
//在这里构造线程, 负责执行具体任务
public MyTimer() {
Thread t = new Thread(() -> {
while (true) {
try {
//阻塞队列 , 只有阻塞的入队列和阻塞的出队列 , 没有阻塞的查看对首元素.
synchronized (locker) {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis();
if (myTask.time <= curTime) {
//时间到了 , 可以执行任务了
myTask.runnable.run();
}else {
//时间还没到
//把刚才取出来的任务 , 重新塞回队列中
queue.put(myTask);
locker.wait(myTask.time - curTime );
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public class ThreadDemo23 {
public static void main(String[] args) {
// ms 级别的时间戳 , 当前时刻和基准时刻的 ms之差 , 基准时刻 : 1970 年 1 月 1 日 00:00:00
//System.out.println(System.currentTimeMillis());
MyTimer myTimer = new MyTimer();
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello2");
}
},2000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello1");
}
},1000);
System.out.println("hello0");
}
上述代码中 , 使用wait 等待而不是 sleep , wait方便随时提前唤醒 , wait 的参数是"超时时间" , 时间到达一定程度后 , 还没有 notify 就不等 , 如果时间还没到 , 就 notify 立即返回
5.线程池
线程池的最大好处就是 , 减少每次启动,销毁线程的损耗
从线程池取线程 , 是属于纯用户态操作 , 不涉及到和内核的交互
标准库中的线程池
- 使用 Executors.newFixedThreadPool(10) 能创建出固定的包含10个线程的线程池
- 返回值类型为 ExecutorService
- 通过ExcutorService.submit 可以注册一个任务到线程池中
public static void main(String[] args) {
//创建一个10线程的线程池
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("pool");
}
});
}
Executors 创建线程池的几种方式:
- newFixedThreadPool : 创建固定线程数的线程池
- newCachedThreadPool : 创建线程数目动态增长的线程池
- newSingleThreadExecutor :创建只包含单个线程的线程池
- newScheduleThreadPool : 设定 延迟时间后执行命令 , 或者定期执行 命令 , 是进阶版的 Timer
Executors 本质上是 ThreadPoolExecutor 类的封装
ThreadPoolExecutor 参数 :
-
corePoolSize : 核心线程数
-
maximumPoolSize : 最大线程数
如果当前任务比较多 , 线程池就会多创建一些"临时线程" , 如果当前任务较少 , 线程池就会把多出来的临时工程销毁掉
-
long keepAliveTime : “临时线程” 保持存活的时间
-
TimeUnit unit : 单位 s , 分钟 , ms
-
BlockingQueue workQueue : 线程池里要管理许多任务 , 这些任务也是通过阻塞队列来组织的 , submit 方法其实就是把任务放到该队列中(程序员可以手动指定给线程池一个队列 , 此时程序员就很方便地可以控制/获取队列中的信息了)
-
ThreadFactory threadFactory : 工厂模式 , 创建线程的辅助类
-
RejectedExecutionHandler handler : 线程池的拒绝策略 , 如果线程池满了 吗继续往里添加任务 , 如何进行拒绝
标准库中提供的四种拒绝策略
ThreadPoolExecutor.AbortPolicy | 如果满了,继续添加任务,添加操作直接抛出异常 |
---|---|
ThreadPoolExecutor.CallerRunsPolicy | 添加的线程自己负责执行这个任务 |
ThreadPoolExecutor.DiscardOldestPolicy | 丢弃最老的任务(指的是最先安排的任务) |
ThreadPoolExecutor.DiscardPolicy | 丢弃最新的任务 |
ThreadPoolExecutor 类的构造方法的参数 , 都要重点掌握 ,尤其是拒绝策略
实现线程池
- 核心操作为submit , 将任务加入线程池中
- 使用 Worker 类描述一个工作线程 , 使用 Runnable 描述一个任务
- 使用一个 BlockingQueue 组织所有的任务
- 每个 worker 线程要做的事情 , 不停地从 BlockingQueue 中取任务并执行
- 指定以下线程中最大的线程数 maxWorkerCount , 当当前线程超过这个最大值时, 就不在新增线程了
class MyThreadPool {
//阻塞队列用来存放任务
private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
//此处实现一个固定线程数的线程池
public MyThreadPool(int n) {
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
try {
while (true) {
//此处需要让线程内部有个 while 循环 , 不停地取任务
Runnable runnable = null;
runnable = queue.take();
}
}catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
}
}
public class ThreadDemo25 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool = new MyThreadPool(10);
for (int i = 0; i < 1000; i++) {
//每次循环都是创建一个新的 number ,没有人修改 该number
int number = i;
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello" + number);
}
});
}
Thread.sleep(3000);
}
}
当前代码中 , 有十个线程的线程池 , 实际开发中 , 一个线程池的线程数量 ,设置成绩, 是比较合适的??
答 : 测试!!
不同的程序 , 线程的作用不一样 ;
1. cpu 密集型任务 , 主要做一些计算工作 , 要在cpu 上运行的
2. IO 密集型任务 , 主要是等待IO操作(等待读写硬盘 , 读写网卡)
6.总结–保证线程安全的思路
- 使用没有共享资源的模型
- 使用共享资源只读 , 不写的模型
- 不需要写共享资源的模型
- 使用不可变对象
- 直面线程安全
- 保证原子性
- 保证顺序性
- 保证可见性
线程的优点:
- 创建一个新线程的代价要比创建一个新进程小的多
- 与进程之间的切换相比 , 线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的课并行数量
- 在等待慢速 I/O 操作结束的同时 , 程序可执行其他的计算任务
- 计算密集型应用 , 为了能在多处理器系统上运行 , 将计算分解到多个线程中实现
- I/O 密集型应用 , 为了提高性能 , 将I/O操作重叠 , 线程可以同时等待不同的I/O操作
进程与线程的区别 :
- 进程是系统进行资源分配和调度的一个独立单位 , 线程是程序执行的最小单位
- 进程有自己得内存地址空间 , 线程值独享指令流指令的必要资源 , 如寄存器和栈
- 由于同一进程的各线程间共享内存和文件资源 , 可以不通过内核进行直接通信
- 线程的创建 , 切换及终止效率更高.
证线程安全的思路
- 使用没有共享资源的模型
- 使用共享资源只读 , 不写的模型
- 不需要写共享资源的模型
- 使用不可变对象
- 直面线程安全
- 保证原子性
- 保证顺序性
- 保证可见性
线程的优点:
- 创建一个新线程的代价要比创建一个新进程小的多
- 与进程之间的切换相比 , 线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的课并行数量
- 在等待慢速 I/O 操作结束的同时 , 程序可执行其他的计算任务
- 计算密集型应用 , 为了能在多处理器系统上运行 , 将计算分解到多个线程中实现
- I/O 密集型应用 , 为了提高性能 , 将I/O操作重叠 , 线程可以同时等待不同的I/O操作
进程与线程的区别 :
- 进程是系统进行资源分配和调度的一个独立单位 , 线程是程序执行的最小单位
- 进程有自己得内存地址空间 , 线程值独享指令流指令的必要资源 , 如寄存器和栈
- 由于同一进程的各线程间共享内存和文件资源 , 可以不通过内核进行直接通信
- 线程的创建 , 切换及终止效率更高.