线程池、锁策略
目录
线程池
上古时期,服务器处理多个客户端的请求,是基于多进程的模型。每次有个客户端请求过来了,服务器这边都创建一个进程给这个客户端提供服务(读取请求,解析请求,返回响应…….)。后来,频繁创建销毁进程,效率比较低,所以引入了线程
引入线程后,模型就成了,每个客户端都分配一个线程提供服务。随着客户端数量越来越多,发现频繁创建销毁线程,也变的比较低效了,又引入了线程池
线程池最主要的还是解决服务器开发的问题,假设线程池里提前准备好10个线程,有100个客户端吧请求发过来,把这100个客户端的请求封装成任务(Runnable)添加到线程池里,线程池中由这10个线程负责处理这100个任务,这个过程就不涉及线程的创建销毁了
模拟实现线程池
1. 核心操作为 submit,将任务加入线程池中,任务就是 runnable
2. 使用 Worker 类描述一个工作线程,使用 Runnable 描述一个任务
3. 使用一个 BlockingQueue 组织所有的任务
4. 每个 worker 线程要做的事情:不停的从 BlockingQueue 中取任务并执行
5. 指定一下线程池中的最大线程数 maxWorkerCount;当当前线程数超过这个最大值时,就不再新增线程了
// 实现一个固定线程个数的线程池
class MyThreadPool {
private BlockingQueue<Runnable> queue = null;
public MyThreadPool(int n) {
// 初始化线程池,创建固定个数的线程
// 这里使用ArrayBlockingQueue作为任务队列, 容量为1000
queue = new ArrayBlockingQueue<>(1000);
// 创建 N 个线程
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
try {
while (true) {
Runnable task = queue.take();
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// t.setDaemon(true); // 设置为后台线程
t.start();
}
}
public void submit(Runnable task) throws InterruptedException {
// 将任务放入队列中
queue.put(task);
}
}
public class Demo35 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool = new MyThreadPool(10);
// 向线程池提交任务
for (int i = 0; i < 100; i++) {
int id = i;
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " id=" + id);
});
}
}
}
虽然100个任务已经结束了但是进程仍然没有结束,此时线程池里的这些线程,还在 take 阻塞,说明线程池中的线程,是前台线程,会阻止进程结束!

补充知识:
手动停止上述程序,显示的是数字 130,这个数字表示程序的退出码
操作系统中约定了:退出码为 0,表示正常结束;非0表示异常结束(不同的数字表示不同的原因)
正常结束:
定时器
定时器是软件开发中的一个重要组件,类似于一个 “闹钟”,达到一个设定的时间之后,就执行某个指定好的代码
定时器是一种实际开发中非常常用的组件。比如网络通信中,如果对方 500ms 内没有返回数据,则断开连接尝试重连。比如一个Map,希望里面的某个 key 在 3s 之后过期(自动删除)。类似这样的场景就需要用到定时器
标准库中的定时器
- 标准库中提供了一个 Timer 类,Timer 类的核心方法为 schedule
- schedule 包含两个参数,第一个参数指定即将要执行的任务代码,第二个参数指定多长时间之后执行(单位为毫秒)
public class Demo37 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello 3000");
}
}, 3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello 2000");
}
}, 2000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello 1000");
}
}, 1000);
System.out.println("hello main");
}
}

1. TimerTask 实现了 Runnable,核心还是重写 run 方法
2. 和线程池一样,Timer中也包含前台线程,会阻止进程结束
实现定时器
1. 创建一个类,表示一个任务
2. 定时器中,要能够管理多个任务,必须使用一些集合类把这多个任务给管理起来。这里使用优先级队列(不要使用 PriorityBlockingQueue,容易死锁)
ArrayList 是否可行呢?
不可行。当管理多个任务的时候,需要确保,时间最早的任务最先执行,ArrayList 要通过遍历的方式,找到时间最早的任务,指定的时间可能不准
3. 实现 schedule 方法。把任务添加到队列中即可
4. 额外创建一个线程,负责执行队列中的任务。和线程池不同,线程池是只要队列不为空,就立即取任务并执行。这里需要看队首元素的时间是否到了,时间到才能执行,时间不到不能执行
// 基于抽象类的方式定义 MyTimerTask 的写法
// 这样的定义虽然确实可以, 但写起来有点麻烦,还有另外的写法
//abstract class MyTimerTask implements Runnable {
// @Override
// public abstract void run();
//}
class MyTimerTask implements Comparable<MyTimerTask> {
private Runnable task;
// 记录任务要执行的时刻
private long time;
public MyTimerTask(Runnable task, long time) {
this.task = task;
this.time = time;
}
@Override
public int compareTo(MyTimerTask o) {
return (int) (this.time - o.time);
// return (int) (o.time - this.time);
}
public long getTime() {
return time;
}
public void run() {
task.run();
}
}
// 自己实现一个定时器
class MyTimer {
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
// 直接使用 this 作为锁对象, 当然也是 ok 的
private Object locker = new Object();
public void schedule(Runnable task, long delay) {
synchronized (locker) {
// 以入队列这个时刻作为时间基准.
MyTimerTask timerTask = new MyTimerTask(task, System.currentTimeMillis() + delay);
queue.offer(timerTask);
locker.notify();
}
}
public MyTimer() {
// 创建一个线程, 负责执行队列中的任务
Thread t = new Thread(() -> {
try {
while (true) {
synchronized (locker) {
// 取出队首元素
// 这里使用 while
while (queue.isEmpty()) {
locker.wait();
}
MyTimerTask task = queue.peek();
if (System.currentTimeMillis() < task.getTime()) {
// 当前任务时间, 如果比系统时间大, 说明任务执行的时机未到
locker.wait(task.getTime() - System.currentTimeMillis());
} else {
// 时间到了, 执行任务
task.run();
queue.poll();
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
}
public class Demo38 {
public static void main(String[] args) {
MyTimer timer = new MyTimer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 3000");
}
}, 3000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 2000");
}
}, 2000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello 1000");
}
}, 1000);
// 带有线程池的计时器
Executors.newScheduledThreadPool(4);
}
}
说明:
1. 优先级队列必须要明确的比较规则。如果元素是 数字 或 String 这种本身就有明确比较规则的对象,可以不额外指定
2. 调用schedule是一个线程,定时器内部又有一个线程,多个线程操作同一个队列,一定涉及到线程安全问题,需要使用锁
3. 锁加到下图这个位置,可能会因为 synchronized 阻塞,使得时间存在一点误差,所以建议把 timeTask 也放到锁里
4. 标准库提供的 Timer 和自己实现的 MyTimer 差不多,都是使用一个线程,负责扫描队首元素并执行的。如果任务少或任务的时间分散问题不大,如果任务特别多,时间非常集中一个线程就可能执行不过来。更好的做法是 一个线程负责扫描,扫描到需要执行的任务,添加到另一个线程池的任务队列中,由多个线程负责执行
5. 标准库中带有线程池的计时器:Executors.newScheduledThreadPool();
时间轮
除了基于堆(优先级队列)的方式来实现的定时器之外,还有一种方案,基于 “时间轮”
时间轮类似循环队列(数组),每个元素是一个 “时间单位”,每个元素又是一个链表。每到一个时间单位,光标指向下一个元素,同时把这个元素上对应链表中的任务都执行一遍
优势:性能更高(优先级队列有堆调整的开销)
劣势:时间精度不如优先级队列
时间轮更适合任务特别多的情况
优先级队列更适合精度高的情况
由于定时器,是一个非常重要的组件,以至于在分布式系统中,会把定时器专门提取出来,封装成一个单独的服务器(和消息队列很像)
常见的锁策略
如果你认为标准库提供的锁不够用,想自己实现一把锁,这时需要关注锁策略(其实 synchronized 已经非常好用了,足矣覆盖绝大多数的使用场景)
“锁策略” 不是和Java强相关的。其他语言,但凡涉及到并发编程,涉及到锁,都可以谈到锁策略
锁策略,就是锁在加锁时特点和行为
乐观锁 vs 悲观锁
描述的是加锁时遇到的场景
悲观锁:加锁的时候,预测接下来的锁竞争的情况非常激烈,就需要针对这样的激烈情况额外做一些工作
有一把锁,有二十个线程尝试获取锁,每个线程加锁的频率都很高。一个线程加锁的时候,很可能锁被另一个线程占用着 。这种情况用悲观锁的方式处理
乐观锁:加锁的时候,预测接下来的锁竞争的情况不激烈,不需要做额外工作
有一把锁,假设只有两个线程尝试获取这个锁。每个线程加锁的频率都很低,一个线程加锁的时候,大概率另一个线程没有和他竞争。这种情况用乐观锁的方式处理
重量级锁 vs 轻量级锁
遇到场景之后的解决方案
重量级锁,在悲观的场景下,就要付出更多的代价(更低效)
轻量级锁,应对乐观的场景,付出的代价就会更小(更高效)
挂起等待锁 vs 自旋锁
自旋锁:轻量级锁的典型实现
是应用程序级别的。加锁的时候发现有竞争,一般不是进入阻塞,而是通过忙等的形式进行等待
忙等:连续测试一个变量直到某个值出现为止,称为忙等。忙了很长时间,又没有实质性的产出,还持续占着 CPU 而不释放,这个时候应该释放 CPU 让给其他进程
^
在乐观锁的场景下,遇到锁竞争的概率很小,就算遇到竞争,也能在短时间内就能拿到锁,获取锁的周期很短,所以使用忙等的方式影响不大
挂起等待锁:重量级锁的典型实现
是操作系统内核级别的。加锁的时候发现有竞争,就会使该线程进入阻塞状态,后续由内核进行唤醒
获取锁的周期更长,很难做到及时获取,所以这个过程就不必使用忙等的方式一直消耗 cpu 了,把 cpu 省出来做别的事情
synchronized 是悲观 / 乐观呢?
答:既是悲观又是乐观,可以自适应。
VM内部会统计每个锁竞争的激烈程度,如果竞争不激烈,synchronized 就会按照轻量级锁(自旋),如果竞争激烈,synchronized 就会按照重量级锁(挂起等待)
普通互斥锁 vs 读写锁
普通互斥锁:synchronized;只有加锁、解锁两个操作
读写锁:有两种加锁方式:读方式加锁、写方式加锁
1. 多个线程读取一个数据,本身就是线程安全的。多个线程读取,一个线程修改,肯定会涉及到线程安全问题。如果大部分操作在读,少数操作在写,把读和写都加普通的互斥锁,锁冲突非常严重,所以就引入了读写锁
2. 读写锁可以确保,读锁和读锁之间不是互斥的(不会产生阻塞),写锁和读锁之间,才会产生互斥,写锁和写锁之间,也有互斥。
3. 读写锁保证线程安全的前提下,降低锁冲突的概率,提高效率
4. 读写锁适合 读多,写少 的情况,这种情况在服务器开发中很常见
Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁

这两个内部类各自提供了 lock / unlock 方法进行加锁解锁

可重入锁 vs 不可重入锁
一个线程,一把锁,连续加锁多次,是否会死锁,死锁了就是不可重入,没有死锁就是可重入
synchronized是 “可重入锁”
核心要点:
1. 在锁中记录当前是哪个线程拿到的这把锁
2. 使用计数器,记录当前加锁了多少次,在合适的时候进行解锁
公平锁 vs 非公平锁
假设三个线程 A,B,C。A 先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待;然后 C 也尝试获取锁,C也获取失败,也阻塞等待。当线程 A 释放锁的时候,B 和 C 谁先拿到锁呢?
公平锁:遵守 “先来后到”,B 比 C 先来的,当 A 释放锁的之后,B 就能先于 C 获取到锁
非公平锁:不遵守 “先来后到” B 和 C 都有可能获取到锁(synchronized 就是非公平锁)
要想实现公平锁,需要付出额外的代价,比如,需要使用一个队列,记录一下各个线程获取锁的顺序
总结
1. synchronized 在上述策略中,是什么样的锁?
是自适应的,乐观锁、悲观锁、重量级锁、轻量级锁、挂起等待锁、自旋锁,这些都可以是;
不是读写锁;是可重入锁,非公平锁
2. 介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁
- 读锁和读锁之间不互斥
- 写锁和写锁之间互斥
- 写锁和读锁之间互斥
读写锁最主要用在“频繁读,不频繁写”的场景中
3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁
相比于挂起等待锁,
优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁,更高效;在锁持有时间比较短的场景下非常有效
缺点:如果锁的持有时间较长,就会浪费CPU资源
4. synchronized 是可重入锁么?
是可重入锁
可重入锁是指连续两次加锁不会导致死锁.
实现的方式是
1)在锁中记录当前是哪个线程拿到的这把锁
2)使用计数器,记录当前加锁了多少次,在合适的时候进行解锁
synchronized 原理
基本特点
结合上面的锁策略,可以总结出 synchronized 具有以下特性(JDK1.8):
1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁
2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁
3. 实现轻量级锁时,大概率用的是自旋锁策略
4.是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
加锁工作过程
JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,依次进行升级

偏向锁
1)加锁时,刚一上来不是真加锁,而是只是简单做一个标记,这个标记非常轻量,相比于加锁解锁来说,效率高很多。
2)如果没有其他线程来竞争这个锁,最终当前线程执行到解锁代码,也就只是简单清除上述标记即可(不涉及真加锁,真解锁)
3)如果有其他线程来竞争,会抢先一步,在另一个线程拿到锁之前,抢先拿到锁,此时才是真正加上锁(其他线程只能阻塞等待),偏向锁就升级成轻量级锁了
4)偏向锁也是懒汉模式思想的体现
锁升级的时机/条件:
无锁 => 偏向锁:代码进入 synchronized 的代码块
偏向锁 => 轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁
轻量级锁 => 重量级锁:JVM发现,当前竞争锁的情况非常激烈
JVM 中,只能 “锁升级” 不能 “锁降级” 。可能是因为,实现降级的收益不大,并且会使 JVM 相关代码的复杂度更高
更多推荐




所有评论(0)