- 前言:
- 一、分析上篇多线程不安全原因
- 1. count++ 操作是三个步骤,load add save
- 2. 多个线程之间的调度是无序的,两个线程的上述三个操作可能存在多种不同的相对顺序
- 3. 线程针对变量的修改不是原子的
- 4. 内存可见性
- 5.指令重排序
- 二、synchronize 关键字-监视器锁
- 1.synchronized 的特性
- 2. synchronized 使用示例
- 3.修饰静态方法
- 4.可重入锁
- 三、 Java 标准库中的线程安全类
- 四、volatile 和synchronize区别
- 五、wait 和 notify
- notify()方法
- notifyAll()方法
- 六、多线程相关的代码案例
- 6.1 饿汉模式
- 6.2 懒汉模式
- 6.3单例模式的线程安全问题(面试经典问题)
- 6.4 阻塞队列
- 6.4.1 线程安全阻塞队列
- 6.5 生产者消费者模型
- 6.5.2 生产者消费者模型代码实现
- 七、 定时器
- 八、 线程池
- 九、总结篇
- 1.如何保证线程安全的思路
- 2.线程的优点
- 3.进程与线程的区别
前言:
这一篇文章是接着上一篇的,因为上一篇篇幅有点长了,太长会影响阅读,所以也是重新写一篇,这个多线程系列会一直更下去,也期待完成的那一天,还是老样子,有问题直接评论区留言or私信我,看见都会解决的。
一、分析上篇多线程不安全原因
上一篇是讲到线程不安全,典型的案列是:
当两个线程并发针对count变量进行自增,此时会出现bug
1. count++ 操作是三个步骤,load add save
2. 多个线程之间的调度是无序的,两个线程的上述三个操作可能存在多种不同的相对顺序
- 解决方案,加锁synchronize,进入synchronize会先加锁,出了这个方法会解锁,如果当前线程占用这把锁,其他锁来占用会出现阻塞等待
线程不安全的原因:
- 【根本原因】线程的抢占式执行(对于这个原因无可奈何)
- 两个线程在修改同一个变量
3. 线程针对变量的修改不是原子的
(不可以拆分的最小单位,像++ 拆分变成 load add save)
4. 内存可见性
内存可见性:
场景:有一个变量,一个线程循环快速的读取这个变量的值,另一个线程会在一定时间之后,修改这个变量(来看代码)
public class Demo16 {
private static int isQuit =0;
public static void main(String[] args) {
Thread t =new Thread(()->{
while(isQuit==0) {
}
System.out.println("t 线程结束");
});
t.start();
//在主线程中,通过scanner 让用户输入一个整数,把输入的值赋值给isQuit,从而影响到t线程退出。
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
isQuit = scanner.nextInt();
System.out.println("main 线程结束");
}
}
按道理来说把输入的值赋给isQuit的话 就while结束了,但是还是没有这就是一个bug
为什么呢?
这里就要提到java中的编译器优化了,程序猿写的代码,java编译器在编译的时候,并不是原封不动的逐字翻译,会在保证原有的逻辑不变的前提下,动态调整要执行的指令内容,这个调整的过程,是需要保证原有的逻辑不变的,但是这个调整能提高效率~
主流的编译器都会这类编译器的优化,这样的优化会大幅度提高代码效率,但是上面的代码就是优化过头了,我们来看看上面代码的图流程:
- 在多线程的环境下 isQuit的值可能会发生改变,但是编译器在进行的时候,没法对于多线程的代码做出过多的预判只是看到了t线程内部没有地方在修改isQuit,
- 并且这个t线程里,要反复执行太多次操作(读内存,我们以前说过从内存读数据,要比在寄存器读内存要慢个3-4个数量级)
- 在t中,编译器的直观感受,就是反复进行太多次的load,太慢了,同时,这里load得到结果还一直不变,所以编译器做出了大胆的优化操作,直接省略这里的load(只保留第一次)后续的操作,都不再重新读内存了,而是直接从寄存器读。
所以大概的意思就是被优化了,isQuit的数改了也没有反应,一直在0和0在比。
针对内存可见性的问题,解决方案有两种:
- 还是synchronize,就会禁止编译器在synchronize内部产生上述的优化
- 还可以使用另一个关键字volatile
要要用这个关键字修饰对应的变量即可,一旦有了这个关键字,编译器优化的时候,就知道了会禁止进行上述的读内存的优化,会保证每次都重新从内存读,哪怕速度慢一些,(主要还是保证内存可见性)
public class Demo16 {
private static volatile int isQuit =0;
public static void main(String[] args) {
Thread t =new Thread(()->{
while(isQuit==0) {
}
System.out.println("t 线程结束");
});
t.start();
//在主线程中,通过scanner 让用户输入一个整数,把输入的值赋值给isQuit,从而影响到t线程退出。
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
isQuit = scanner.nextInt();
System.out.println("main 线程结束");
}
}
5.指令重排序
这个也可能会引起线程不安全的问题,这个也是和编译器存在关联的(也是一种优化手段)
触发指令重排序的前提,也是要保证代码的逻辑不变
上面的图,结果一样的,但是只要调整了顺序就会快好多,指令重排序,就是保证代码原有逻辑不变,调整了程序指令的执行顺序,从而提高了效率,(如果是单线程)判断还比较准确,但是在多线程这里判断节不一定准确了。
二、synchronize 关键字-监视器锁
1.synchronized 的特性
-
互斥
-
保证内存可见性
-
禁止指令重排序
2. synchronized 使用示例
- 直接修饰普通方法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
2.加到一个代码块上,需要手动的指定一个“锁对象”
我们重点要理解,synchronized 锁的是什么. 两个线程竞争同一把锁, 才会产生阻塞等待.
我们可以看见2个滑稽哥,去针对一个ATM去争,第一个ATM机上锁,后面的人进不去,但是我们可以去隔壁的ATM机,所以说上锁也是要分对象的。
只有针对这个指定的对象上了锁之后,此时多个线程尝试操作相关的对象才会产生竞争,代码来看:
class Count{
public int count = 0;
public void increase(){
synchronized (this){
count++;
}
}
}
在java中,任何一个继承自object的类的对象,都可以作为锁对象(synchronize加锁操作,本质上是操作object对象头中的一个标志位)
3.修饰静态方法
加到一个static方法上,此时相当于指定了当前的类对象,为锁对象
类对象:里面包含了这个类中的一些关键信息 这些关键信息就支持了java的反射机制,类对象和普通对象一样,也是可以被加锁的
public class Demo17 {
//使用这个对象来作为加锁的对象(任意对象都可以作为锁对象)
private static Object locker1 = new Object();
public static void main(String[] args) {
Thread t1= new Thread(()->{
Scanner scanner = new Scanner(System.in);
while(true){
synchronized (locker1){
//获取到锁让他在这里阻塞,通过scanner阻塞
System.out.println("请输入一个整数");
int a = scanner.nextInt();
System.out.println("a ="+a);
}
}
});
t1.start();
//为了保证t1可以先拿到锁 然后t2再运行
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t2 = new Thread(()->{
synchronized (locker1){
System.out.println("hello t2");
}
});
t2.start();
}
}
让t1先拿到locker1这个锁,然后就阻塞(这里的scanner只是为了阻塞达到的效果)
t2在尝试获取到这个锁的时候,由于t1已经占用了锁,所以t2线程无法获取到锁,就只能阻塞等待
没有打印t2线程日志,当前t2是阻塞的
上面说是到第40行产生了阻塞状态,导致Blocked
尝试让t2拿锁,发现也是没有用
代码调整一下:
t2已经执行了:
由于没有竞争同一把锁,这就意味着两个线程之间不会有任何锁的竞争。
4.可重入锁
是synchronize的一个重要的特性,如果synchronize不是可重入,那么就很容易出现“死锁”的情况
class Count{
public int count = 0;
synchronized public void increase(){
synchronized (this){
count++;
}
}
}
- 调用increase的时候,先进行加锁操作,针对this来加锁(此时this就是一个锁定的状态了,把this对象中的标志位给设置上了)
- 继续执行到下面的代码块的时候,也会尝试再次加锁(由于此时this已经是锁定状态,按照之前的理解 这里的加锁操作就会出现阻塞)
这里的阻塞要等到之前的代码把锁释放了,要执行完这个方法,锁才能释放,但是由于此时的阻塞,导致当前这个方法没法继续执行了(僵住了)
java设计锁的大佬考虑到了这样的情况,于是就把当前的synchronize设计成可重入锁,针对同一个锁连续加锁多次,不会有负面效果:
锁中持有这样的两个信息:
- 当前这个锁被哪个线程给持有了
- 当前这个锁被加锁几次了
(当前线程t如果已经被加锁了,就会自己判断出,当前的锁就是t持有的,第二加锁并不是真的加锁,只是进行了一个修改计数(1->2))
(后续往下执行的时候,出了synchronize代码块,就触发一次解锁,也不是真的解锁而是计数-1),在外层方法执行完了之后,再次解锁,再次计数-1,计数为0才进行真正的解锁
死锁出现的情况,不仅仅是上述的这一种情况(针同一把锁,连续加锁两次)
- 一个线程,一把锁
- 两个线程,两把锁
- N个线程,N把锁
三、 Java 标准库中的线程安全类
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
上面的这些谨慎在多线程环境下使用,尤其是一个对象被多个线程修改的时候,
Vector (不推荐使用)
HashTable (不推荐使用)
ConcurrentHashMap
StringBuffer
上面几个类线程是安全的,核心方法上带有synchronize关键字,更加放心的多线程环境下使用的
还有String也是线程安全的,比较特殊,是因为String是不可变对象(不能被修改)因此就不能在多线程中修改同一个String 了
四、volatile 和synchronize区别
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了
没加volatile 关键字
public class Demo16 {
private static int isQuit =0;
public static void main(String[] args) {
Thread t =new Thread(()->{
while(isQuit==0) {
}
System.out.println("t 线程结束");
});
t.start();
//在主线程中,通过scanner 让用户输入一个整数,把输入的值赋值给isQuit,从而影响到t线程退出。
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
isQuit = scanner.nextInt();
System.out.println("main 线程结束");
}
}
线程结束不了。
加上volatile关键字:
class Count{
public volatile int count = 0;
public void increase(){
count++;
}
}
public class Demo15 {
private static Count counter =new Count();
public static void main(String[] args) throws InterruptedException {
//创建两个线程,两个线程分别对counter调用5w次 increase操作
Thread t1 = new Thread(()->{
for (int i =0; i<50000 ;i++){
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i =0; i<50000 ;i++){
counter.increase();
}
});
t1.start();
t2.start();
//阻塞等待线程累加结束
// 如果是t2先执行完t1后执行完也没事, t1.join ,main就会阻塞等待t1线程,这个时候t2执行完了,t1还没有执行完
// 过了一会,t1线程执行完了,于是t1.join就返回,继续调用t2.join,由于t2已经执行完了的t2.join 可以立马返回,不必阻塞等待
t1.join();
t2.join();
System.out.println(counter.count);
}
}
加上synchronize,结果正确了
synchronized 既能保证原子性, 也能保证内存可见性
public class Demo16 {
private static volatile int isQuit =0;
public static void main(String[] args) {
Thread t =new Thread(()->{
while(true) {
synchronized (Demo16.class){
if (isQuit !=0){
break;
}
}
}
System.out.println("t 线程结束");
});
t.start();
//在主线程中,通过scanner 让用户输入一个整数,把输入的值赋值给isQuit,从而影响到t线程退出。
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
isQuit = scanner.nextInt();
System.out.println("main 线程结束");
}
}
注意while(true)地方!!
五、wait 和 notify
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.
但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺
通过wait, notify机制,当某个线程调用了wait之后,就阻塞等待,直到其他某个线程调用了notify,才把这个线程唤醒为止
假设有两个线程,t1,t2,希望先执行t1,t1执行了一些工作,再执行t2,就可以先让t2wait,然后t1执行一些工作,调用notify唤醒t2
注意: wait, notify, notifyAll 都是 Object 类的方法
public class Demo18 {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
System.out.println("等待之前");
o.wait();
System.out.println("等待之后");
}
}
为什么会有这个报错,因为这个o这个对象的锁状态不对,针对一个没有加锁的对象进行解锁操作,就会出现上述的异常
wait这个方法里会做三件事情:
- 先针对o解锁
- 进行等待
- 当通知到来之后,就会被唤醒,同时尝试重新获取到锁,然后再继续执行
- 正因为wait里面做了这几件事,所以wait才需要搭配synchronize来使用
代码修改变成这样的就可以了。
public class Demo18 {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
synchronized(O){
System.out.println("等待之前");
o.wait();
System.out.println("等待之后");
}
}
}
注意: wait, notify, notifyAll 都是 Object 类的方法
notify()方法
哪个对象调用的wait,就需要哪个对象调用notify来唤醒
比如 O1.wait()就需要01.notify 来唤醒
o1.wait(),使用o2.notify,没啥效果
notify同样要搭配synchronize来使用
如果有多个线程都在等待,调用一次notify,只能唤醒其中一个线程,具体唤醒的是谁,不知道(随机的),
如果没有任何线程等待,直接调用notify,不会有副作用
public class Demo19 {
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread waiter = new Thread(() ->{
while(true){
synchronized (locker){
System.out.println("wait 开始");
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 结束");
}
}
});
waiter.start();
Thread.sleep(3000);
Thread notifier = new Thread(()->{
synchronized (locker){
System.out.println("notify 之前");
locker.notify();
System.out.println("notify 之后");
}
});
notifier.start();
}
}
结合wait和notify就可以针对多个线程之间的执行顺序进行一定的控制,java中除了刚才的notify(一次随机唤醒一个线程)之外,还有一个操作叫做notifyAll(一下子全部唤醒,唤醒之后,这些线程再尝试竞争这同一个锁)
notifyAll()方法
notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.+
理解 notify 和 notifyAll
notify 只唤醒等待队列中的一个线程. 其他线程还是乖乖等着
notify场景:
六、多线程相关的代码案例
基于我们上面学的,我们可以使用多线程来写一个多线程相关的代码案例
案列一:实现一个线程安全版本的单例模式代码(设计模式)
在有些程序中,某些类在代码只应该有一个实例,而不应该有多个就可以称为是单例。实现一个代码,保证这个类不会被创建多个实例,此时这样的代码就叫单例模式。单例模式分为饿汉模式和懒汉模式
6.1 饿汉模式
class SingletonDataSource{
//类属性 被static修饰的成员 是类的属性
//一个类对象在一个程序中也就存在一份(JVM)保证的
private static SingletonDataSource instance = new SingletonDataSource();
//构造方法 设置为private 外面的人没有办法调用构造方法 无法创建实例
private SingletonDataSource (){
}
//外界使用访问的唯一通道
public static SingletonDataSource getInstance(){
return getInstance();
}
}
public class Demo1 {
public static void main(String[] args) {
//无论在代码中的哪个地方来调用这里的 getInstance得到的都是同一个实例。
SingletonDataSource dataSource = SingletonDataSource.getInstance();
}
}
6.2 懒汉模式
class SingletonDataSource2{
private static SingletonDataSource2 instance = null;
private SingletonDataSource2 (){
}
// 和饿汉模式相比,懒汉模式主要的差别在于这个实例的创建时机不同。不再是类加载的时候就立即创建实力而是在首次调用getInstance的时候。才会真正创建实例。
public static SingletonDataSource2 getInstance(){
if (instance == null){
instance =new SingletonDataSource2();
}
return getInstance();
}
}
public class Demo1 {
public static void main(String[] args) {
//无论在代码中的哪个地方来调用这里的 getInstance得到的都是同一个实例。
SingletonDataSource2 dataSource = SingletonDataSource2.getInstance();
}
}
和饿汉模式相比,懒汉模式主要的差别在于这个实例的创建时机不同。不再是类加载的时候就立即创建实力而是在首次调用getInstance的时候。才会真正创建实例.
好比如:
使用饿汉模式的话,有4个碗就洗4个碗
使用懒汉模式的话,有4个碗,后面发现下一顿饭只要2个碗,洗2个碗就比洗4个碗快多了。
6.3单例模式的线程安全问题(面试经典问题)
对于饿汉模式来说,多线程调用getInstance只在针对同一个变量来读。线程安全
对于懒汉模式来说,多线程调用getInstance。大部分情况下也是读,但是也可能会修改。线程不安全
发生在初始化的时候
我们想像一下: 我们有2个线程,那么我们饿汉模式一开始2个instance都是null,那么在它们都为null的时候,就执行if里面的代码:
这样可能就存在了 2个new SingletonDataSource2()。
可别小瞧出现了2个,如果一个实例是10G,那么2个实例就是20G ,我们白白浪费了这么多空间,是不划算的,
我们解决的办法是使用:加锁!
把读和修改打包变成一个原子操作
对于懒汉模式线程不安全只是出现在,未初始化的时候,(instance == null)
如果我们初始化好了的话,在调用就没有线程安全的问题了,所以我们代码还可以改一下
在instance实例化前加锁,实例化后就不加锁:
class SingletonDataSource2{
private static SingletonDataSource2 instance = null;
private SingletonDataSource2 (){
}
// 和饿汉模式相比,懒汉模式主要的差别在于这个实例的创建时机不同。不再是类加载的时候就立即创建实力而是在首次调用getInstance的时候。才会真正创建实例。
public static SingletonDataSource2 getInstance(){
if (instance == null){
synchronized (SingletonDataSource2.class){
if (instance == null){
instance =new SingletonDataSource2();
}
}
}
return getInstance();
}
}
public class Demo1 {
public static void main(String[] args) {
//无论在代码中的哪个地方来调用这里的 getInstance得到的都是同一个实例。
SingletonDataSource2 dataSource = SingletonDataSource2.getInstance();
}
}
我们要理解 2个if的区别:
第一个if是判断是否加锁,第二个if是判断是否创建实例
但是我们这里还是有点问题,如果有很多线程去调用instance,就会变成了instance的内存值被多次读取,然后可能触发编译器优化,就造成不从内存读了,从cpu的寄存器去读,就会造成内存的可见性情况, 如果有一个线程给创建实例改了的话,那后面的线程也不知道,也会让后面的线程白白加锁。
所以我们代码在优化一下:
class SingletonDataSource2{
private static volatile SingletonDataSource2 instance = null;
private SingletonDataSource2 (){
}
// 和饿汉模式相比,懒汉模式主要的差别在于这个实例的创建时机不同。不再是类加载的时候就立即创建实力而是在首次调用getInstance的时候。才会真正创建实例。
public static SingletonDataSource2 getInstance(){
if (instance == null){
synchronized (SingletonDataSource2.class){
if (instance == null){
instance =new SingletonDataSource2();
}
}
}
return getInstance();
}
}
public class Demo1 {
public static void main(String[] args) {
//无论在代码中的哪个地方来调用这里的 getInstance得到的都是同一个实例。
SingletonDataSource2 dataSource = SingletonDataSource2.getInstance();
}
}
6.4 阻塞队列
队列:先进先出(最常见的队列)
阻塞队列属于一种比特殊的队列(也是遵守先进先出)
- 线程安全队列
- 带有 “阻塞功能” 具体来说,如果队列为空,尝试进行出队列就会阻塞。一直阻塞到队列不为空;如果队列满,尝试进行近队列就会阻塞,一直阻塞队列不为满为止
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new LinkedBlockingDeque<>();
// BlockingQueue 提供了入队列put,出队列的方法take ,它们带有阻塞功能的
// offer 和poll 没有阻塞功能
queue.put("hello");
String s = queue.take();
System.out.println(s);
}
}
6.4.1 线程安全阻塞队列
我们现在来写一个线程安全的阻塞队列:
class MyBlockingQueue{
//
private int[] items = new int [1000];
//记录size
private int size = 0;
//记录head
private int head = 0;
//记录tail
private int tail = 0;
//锁对象
private Object locker = new Object();
public void put(int num) throws InterruptedException {
synchronized (locker){
if (size == items.length){
//队列满了 触发阻塞
//此处的等待条件是队列满了,当不满的时候就需要被唤醒
//每次take成功 队列就不满了
locker.wait();
}
items[tail] = num;
tail++;
//判断是不是已经是最后的元素
if (tail >= items.length){
tail = 0;
}
size++;
//唤醒take的阻塞等待
locker.notify();
}
}
public Integer take() throws InterruptedException {
synchronized (locker) {
if (size == 0) {
//队列是空 不应该出队列,要阻塞等待
//队列被put成功就唤醒
locker.wait();
}
int ret = items[head];
head++;
//判断是不是已经是最后的元素 是最后的就重新从 0开始
if (head >= items.length) {
head = 0;
}
size--;
// 取走元素成功 可以直接唤醒阻塞等待的put 线程
locker.notify();
return ret;
}
}
}
public class Demo3 {
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue queue = new MyBlockingQueue();
queue.put(1);
int s1 =queue.take();
System.out.println(s1);
queue.put(2);
int s2 =queue.take();
System.out.println(s2);
queue.put(3);
int s3 =queue.take();
System.out.println(s3);
queue.put(4);
int s4 =queue.take();
System.out.println(s4);
queue.put(5);
int s5 =queue.take();
System.out.println(s5);
}
}
大家重点要注意 等待的时机,和唤醒的时机。
put的阻塞等待是在元素满了就等着,因为放不下了,take是为空了就等待,取不出去了,反之他们只要 take成功或者put成功就不需要等待了,就可以唤醒了。
6.5 生产者消费者模型
基于阻塞队列,就可以实现 生产者消费者模型,非常经典的代码(实际运用中是非常常用的一种代码写法)
什么是生产者消费者模型?
一种更好的让多线程搭配工作的实现方式:
比如炒菜有3个人,步骤都是:拿刀切菜,生火,翻炒
但是如果他们3个都是这样的顺序,那么就会可能一把刀,被一个人拿了,其他的人拿不到,就一直等着。
如果他们3个人分别各做一个事情,切菜好了,把菜放到旁边菜板上,炒菜的直接拿就可以了,这样切菜的我们可以看成是“生成者”,生火,炒菜可以看变是消费者。菜板就是“交易场所”,交易场所一般可以使用,阻塞队列实现。
那么这个生产者消费者有什么用呢?
1.在开发中起到 服务器之间的 解耦合 的效果
如果A和B服务器是相互调用,那么A要知道B的信息,B要知道A的信息。如果一个服务器挂了,另一个很有可能也出问题
如果使用生产者消费者模型那么就类似与多了一个c的场景 ,a把信息给c ,c转发给b
双方只要认识这个阻塞队列即可,一个挂了,不影响另一个
- 起到一个缓冲作用
如果到流量高峰期的时候,我们就会往阻塞队列里面放部分流量,只要不至于全部冲到目标服务器中,缓解了大部分的压力。
6.5.2 生产者消费者模型代码实现
public class Demo4 {
private static MyBlockingQueue queue =new MyBlockingQueue();
public static void main(String[] args) {
Thread producer = new Thread(()->{
int n = 1;
while (true){
try {
queue.put(n);
n++;
System.out.println("生产者生产了:" + n);
//给消费者加个sleep 让生产的慢 消费的快
//
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
Thread customer = new Thread(()->{
while(true){
try {
int n = queue.take();
System.out.println("消费者消费了:" + n);
//给消费者加个sleep 让生产的快 消费的慢
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
customer.start();
}
}
该代码创建了2个模型,一个生产者一个消费者,使用的阻塞队列是我们自己实现的阻塞队列,当生产者没有生产了,消费者就不可以消费了。
七、 定时器
我们来代码实现一个标准库类似的定时器:
首先我们要看看Java标准库的定时器是怎么样的:
那个new Timer是啥呢? 其实类似于我们的多线程的runnable 接口。
那么我们自己实现的定时器也应该有以下功能:
- 描述:描述清楚任务具体要做什么工作(一段代码)
描述清楚任务什么时候执行(记录任务的时间)- 组织:需要能把新的任务给加进来
从这些任务中找出最快要到时间的任务- 定时器中需要有一个单独的扫描线程(这个线程 就不停的扫描看看堆中最小的元素是否到时间)时间到了就执行任务里面的代码
怎么样找出最快要到时间的任务呢? 我们可以使用堆这个数据结构:
小堆父元素的值小于子元素,堆顶元素(根节点)就是整个堆中的最小的元素
定时器的最基本3个部分已经到齐 我们开始代码实现一下:
import java.util.PriorityQueue;
import java.util.concurrent.PriorityBlockingQueue;
class MyTimer{
static class Task implements Comparable<Task>{
//执行的任务是什么
private Runnable runnable ;
//什么时间去执行,此处的time使用绝对时间戳来表示
private long time;
public Task(Runnable runnable, long after) {
this.runnable = runnable;
this.time = System.currentTimeMillis()+ after;
}
public void run(){
runnable.run();
}
@Override
public int compareTo(Task o) {
//this - o ,把时间小的在前面
//o - this ,把时间大的在前面
return (int)(this.time - o.time);
}
}
//准备好Task类之后,就可以把若干个Task给放到一个堆 这样的数据结构 堆在Java使用PriorityQueue
// PriorityQueue本身是不安全的 所以我们使用PriorityBlockingQueue
private PriorityBlockingQueue<Task> tasks =new PriorityBlockingQueue<>();
//通过这个方法往定时器注册任务
public void schedule(Runnable runnable,long after){
Task task = new Task(runnable,after);
tasks.put(task);
}
private Object locker = new Object();
//创建一个扫描线程,让这个扫描线程不停的取队首元素,判定任务是否可以执行
//实例化的时候就把这个线程构造出来
public MyTimer(){
Thread t = new Thread(()->{
while (true){
//取队首元素 判定时间是不是到了
try {
Task task = tasks.take();
long curTime = System.currentTimeMillis();
if (curTime < task.time ){
//时间还没有到,把任务放回去继续等待
tasks.put(task);
//把等待的时候设置为 执行任务的时间 减去当前时间 3点的任务 现在 2点50 我们只需要等待10分钟后在扫描任务 ,就不需要一直扫描
synchronized (locker){
locker.wait(task.time - curTime);
}
}else{
//时间到了 执行这个任务
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public class DemoTimer {
public static void main(String[] args) {
MyTimer timer =new MyTimer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
},3000);
}
}
其实这里的代码需要注意的点就是:
-
如何去比较时间,把时间排序
-
让扫描任务的线程别一直扫描,而是时间快到了在执行扫描
八、 线程池
为什么要使用线程池?
我们一开始使用进程来实现并发编程,但是如果创建销毁频繁就会感觉到捉襟见肘,
使用多线程之后,如果还是频繁的创建/销毁线程确实比进程快多了。但是也顶不住大量频繁的消耗。因此就会用上线程池。
操作系统去创建销毁线程是一个成本比较高的事情;交给用户态来管理线程,就会成本低不少
用户态就想像为自己去做事情,可以立马去做(可把控)。内核态 ,别人去给你做,你不知道他需要多久做完(不可把控)。
我们先来看看标准库的线程池参数:
先看看 corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue threadFactory, handler
我们先把线程池想像为公司:
公司分为正式员工 和实习生
corePoolSize:(核心线程数)正式员工
maximumPoolSize:(最大线程数)正式员工加实习生数量
keepAliveTime:临时工允许摸鱼的时间(如果达到上述时间,就把对应线程干掉)
unit:表示keepAliveTime的单位(允许摸鱼的时间)
workQueue :可以手动指定一个任务队列,线程池里面也是要有一个队列来组织一大堆任务的【每个任务都用一个 Runnable 来表示的】
threadFactory:线程池里的线程通过啥样的方式来创建的
handler :如果任务队伍满了,新的任务该如何处理。比如:只忽略新的任务;也可以干掉最老的任务;也可以阻塞的等待
Java中的这个线程池,里面的线程是能够支持动态变化的。既可以适应负载高的情况,又可以在负载低的时候减少开销
但是上面的方法太难操作了,还有个封装的版本 Executors类:
1. newFixedThreadPool:创建一个固定线程数的线程池
2. newCachedThreadPool:线程池里面的线程数会动态发生改变
3. newSingletonThread:创建了一个包含单个线程的线程池
4. newScheduledThread:创建了一个类似于定时器的线程池,也是延时执行一个任务
不过一般公司都是使用ThreadPoolExecutor ,因为这样可以指定相关线程的参数,更加可控。
既然我们说是直接指定线程个数,那么我们指定成多少合适呢?
我们这种情况其实给不了具体的数字,给数字都是不靠谱的,我们还是得根据情况来看比如下面的例子:
CPU是10核心的话:
极端情况下.线程的任务 100%的时间都在使用,这个时候我们就不应该把线程数量设置为10.
极端情况下.线程的任务 1%的时间都在使用cpu,99%的时间在阻塞,此时理论的线程数量可以设置为1000, (10 / %1)
实际中 ,通过测试的方式,来找到一个更合适的线程个数来设定(通过压力测试),观察不同线程数的时候,cpu的使用情况,要做到,保证cpu既不会特别空闲,也不会特别紧张.(怕突发情况,比如访问爆增)
我们模拟实现一下线程池:
- 描述一个任务Runnable即可
- 如何去组织多个任务,使用阻塞队列
- 有一组线程来执行这里的任务(这样的线程称为工作线程 不能只有一个)
- 使用一定的数据结构,把这若干个线程组织起来
import com.sun.xml.internal.bind.v2.schemagen.xmlschema.List;
import org.omg.CORBA.INTERNAL;
import java.util.ArrayList;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
class MyThreadPool{
//1. 描述任务,直接使用Runnable
//2. 组织描述任务 直接使用阻塞队列
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
//3. 还需要描述一个工作线程是什么样的
static class Worker extends Thread{
//当前这里的Worker线程有好几个 这些线程要共享一个任务队列
//通过这里的构造方法,把上面创建好的任务队列给传到线程里面,方便线程去取任务
private BlockingQueue<Runnable> queue =null;
public Worker(BlockingQueue<Runnable> queue){
this.queue =queue;
}
@Override
public void run() {
//一个线程要做的工作
while(true){
try {
//如果任务队列中不为空,此时就能立即取出一个任务的执行
//如果任务为空 就会产生阻塞,阻塞到有人加入新的任务为止
Runnable task = queue.take();
task.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
//4. 需要组织若干个工作线程
private ArrayList<Worker> workerList = new ArrayList<>();
//5.搞一个构造方法,指定一下有多少个线程在线程池中
public MyThreadPool (int n){
for (int i = 0; i< n; i++){
Worker worker = new Worker(queue);
//创建好线程让它跑起来
worker.start();
workerList.add(worker);
}
}
//6.实现一个submit来注册任务到线程池中
public void submit(Runnable runnable){
try {
queue.put(runnable);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class DemoMyThreadPool {
public static void main(String[] args) {
MyThreadPool pool = new MyThreadPool(10);
for (int i = 0; i<100;i++){
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
}
九、总结篇
1.如何保证线程安全的思路
- 使用没有共享资源的模型
- 适用共享资源只读,不写的模型(一个线程修改,另一个线程在读取)
- 不需要写共享资源的模型
- 使用不可变对象
- 直面线程安全(重点)
- 保证原子性
- 保证顺序性
- 保证可见性
2.线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
3.进程与线程的区别
- 进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。
- 进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。
- 由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。
- 线程的创建、切换及终止效率更高。
以上就多线程基础篇,接下来是多线程的进阶篇,他们的 区别就是
初阶:比较接地气(即是面试问的,也是工作中用的)
进阶:不太接地气(主要是面试问的,但是工作中涉及很少)