文章目录

  • 1. 概念篇
    • 1.1 认识进程
    • 1.2 进程性质
    • 1.3 操作系统如何管理进程
    • 1.4 多线程和多进程
    • 1.5 时间片
    • 1.6 并发与并行
    • 1.7 内核态与用户态
    • 1.8 进程中的上下文
    • 1.9 进程状态
    • 1.10 多线程的生命周期概览
    • 1.11 小结
  • 2. 线程
    • 2.1 启动一个线程
      • 2.1.1 Thread用法一
      • 2.1.2 Thread 用法二
      • 2.1.3 匿名内部类继承Thread创建
      • 2.1.4 匿名内部类实现Runnable
      • 2.1.5 搭配lambda表达式
      • 2.1.6 调用 run 和调用 start区别
      • 2.1.7 线程效率的简单对比
    • 2.2 Thread类及常见方法
      • 2.2.1 Thread类常见的构造方法
      • 2.2.2 Thread类常见的几种属性
    • 2.3 控制线程的几种方法
      • 2.3.1 创建线程
      • 2.3.2 中断线程
      • 2.3.3 等待一个线程
      • 2.3.4 线程的引用
      • 2.3.5 线程的休眠
    • 2.4 线程状态
  • 3. 线程不安全
    • 3.1 线程不安全经典的案例
    • 3.2 线程不安全原因
      • 3.2.1 线程的抢占式运行
      • 3.2.2 两个线程在修改同一个变量
      • 3.2.3 线程针对变量的修改操作不是原子的
      • 3.2.4 内存可见性
  • 4. 线程不安全解决方案
    • 4.1 最典型的方案就是加锁
    • 4.2 synchronized的使用
      • 4.2.1 直接接到一个普通方法上
      • 4.2.2 加到一个代码块上
      • 4.2.3 加到一个 static 类对象上
      • 4.2.4 可重入锁
    • 4.3 wait和notify搭配
  • 5. 单列和工厂设计模式
    • 5.1 单列模式
      • 5.1.1 饿汉模式
      • 5.1.2 懒汉模式
      • 5.1.3 两种模式的对比
      • 5.1.4 如何解决懒汉模式的线程安全问题
    • 5.2 工厂模式「[B站图灵课堂](https://www.bilibili.com/video/BV1HK4y1a7ZK?p=7)学习的,作为补充,所以叙述的不详细」
      • 5.2.1 简单工厂模式
      • 5.2.2 工厂方法模式
      • 5.2.3 抽象工厂模式
  • 6. 阻塞队列
    • 6.1 阻塞队列概念
    • 6.2 生产者消费者模型
  • 7. 定时器
  • 8. 线程池
  • 9.锁策略
    • 9.1 悲观锁VS乐观锁
    • 9.2 读写锁
    • 9.3 轻量级锁VS重量级锁
    • 9.4 自旋锁VS挂起等待锁
    • 9.5 公平锁VS非公平锁
    • 9.6 可重入锁VS不可重入锁
    • 9.7 小结
    • 9.8 相关面试题
  • 10. CAS
    • 10.1 什么是CAS
    • 10.2 CAS是怎么实现的
    • 10.3 CAS有哪些应用
    • 10.4 ABA问题如何处理
    • 10.5 相关面试题
  • 11. synchronized背后的原理
    • 11.1 基本特点
    • 11.2 加锁工作过程
    • 11.3 其它的优化操作
    • 11.4 相关面试题
  • 12. Callable
    • 12.1 Callable的用法
    • 12.2 相关面试题
  • 13. java.util.concurrent包下的常见类
    • 13.1 ReentrantLock
    • 13.2 原子类介绍
    • 13.3 线程池
    • 13.3 信号量Semaphore
    • 13.4 CountDownLatch
    • 13.5 相关面试题
  • 14. 线程安全的集合类
    • 14.1 多线程环境下使用ArrayList
    • 14.2 多线程环境下使用队列
    • 14.3 多线程环境下使用哈希表
      • 14.3.1 HashTable
      • 14.3.2 ConcurrentHashMap
    • 14.4 相关面试题
  • 15 死锁
    • 15.1 死锁是什么
    • 15.2 如何避免死锁
    • 15.3 相关面试题

1. 概念篇

1.1 认识进程

对于操作系统来说:一个任务就是一个进程

内核观点:但当系统分配资源(CPU时间,内存)的实体

课本概念:程序的一个执行实例,正在执行的程序等

类比,一个程序刚开始就是一个在硬盘上的程序,加载运行时,是为了完成某些任务(如迅雷完成下载任务),而要完成任务,就需要操作系统为该任务提供足够做的资源(如内存,CPU时间,网络,磁盘等),而这一整套任务的执行,我们可以叫做进程

所以,进程是担当分配系统资源(CPU时间,内存)的实体,是具有动态特性的。

1.2 进程性质

  1. 一个跑起来的程序,就称为进程
  2. 进程也可以认为是一个可执行文件跑起来之后的动态的过程
  3. 进程运行会被系统分配一些资源
  4. 进程就是系统资源分配的基本单位
  5. 进程具有独立性

独立性如何实现的

是操作系统给软件提供的一个稳定的环境:给每个进程都分配一个 虚拟地址空间 ,就能够保证每个进程,各自访问各自的内存,没有公共区域,相互之间无法产生影响【A进程如果把内存搞乱了,此时不会对B,C等进程产生任何影响】

所谓 稳定的运行环境: 就是保证一个进程出现问题,不会涉及到其它的进程,更不会涉及到整个系统。

既然维持了独立性,但又如何做到进程间通信呢?

虽然进程之间存在 “独立性”,但是很多时候,又避免不了多个进程之间相互配合来完成一些特定的工作。

操作系统提供了一些 “进程间通信” 这样机制,就可以让进程在有限的情况下进行一些沟通的交互操作。

进程间通信的机制:就是在进程独立性这个基础之上,开了个口子,提供了一些专门的区域,可以让多个进程能够访问到【共享】

操作烯烃提供的进程间通信的机制其实有很多种,最主要用的就是两种:操作文件/网络

1.3 操作系统如何管理进程

  1. 描述:使用 PCB 这样的结构体来表示一个进程的相关属性

  2. 组织:使用一定的数据结构把正在执行的进程给串起来【Linux:双向链表】

    PCB是什么?

    1. pid:进程的身份标示
    2. 内存指针:进程的代码段和数据段都在哪里
    3. 文件描述:进程打开的文件是啥
    4. 状态:
    5. 上下文
    6. 优先级
    7. 记账信息

    这一块属于 常识 知识

操作系统为何进入多进程?

引入进程这个概念主要是为了解决 并发编程 的问题。

通过多进程编程确实能够解决并发编程的问题,但是也带来了一些其它的问题:如果当前任务比较多,人物之间切换的比较频繁,这个时候我们的成本是比较高的。尤其是频繁的 创建/销毁 进程,比较低效。

因此我们一般使用 多线程居多数情况

1.4 多线程和多进程

多进程只需要有一个映像即可,少数情况会用到;多线程编程需要重点掌握

进程开销大的原因

主要就是进程持有的系统资源比较多,创建进程就需要分配资源,销毁进程就需要释放资源。进程间的调度切换也需要让系统在这些资源之间进行切换并保存上下文

上下文:软件运行中所需要的环境【后文中有详细介绍,慢慢往下阅读】

解决进程开销大的方案

  1. 创建进程池(类似于字符串常量池。进程时放的时候并不会真正的销毁,而是入池以备后续使用)
  2. 通过多线程替代多进程【所谓的线程,也叫做“轻量级进程”,Linux中叫做:LWP(light wieght process)】

举个例子:

疫情我们需要大量的医用口罩,但是A工厂的生产效率已经无法满足人民需求,现在有两种方案:

  1. A工厂的创始人财大气粗,重新建立一个新的工厂B来满足市场需求
  2. A工厂的创始人精打细算,A工厂内部扩大生产线,利用空闲资源继续生产口罩,满足市场需求

方案1:比较重量,就是相当于 多进程,要建立工厂就得先场地有,还需要设备

方案2:比较轻量,在同一个工厂中,搞多个生产线,场地还是同一个场地,购买了新的设备,多个设备同时生产

整个进程就是一个 “场地”,进程中的线程就是场地中一个个生产线,每个线程都可以单独干活(执行一组独立的代码);同一个进程中的若干个线程间共用同一个场地

借助多线程的方式来实现 并发编程。如果仍然需要频繁创建/销毁任务,此时只需要创建/销毁线程即可,由于资源是绑定在进程身上的,创建/销毁线程,和进程上的资源关系不大。创建线程,并不需要分配新的资源,销毁线程也不需要释放旧的资源。CPU针对线程进行调度,开销也是小于进程的

线程可以视为是轻量级进程(成本更低的进程)

但是有的时候,随着创建/销毁的频率加大,线程也就显得比较重了

进一步的解决方案:

  1. 线程池
  2. 协程(也叫做“纤程”,比线程还要轻量)

1.5 时间片

现代操作系统(Unix,Linux,MacOS,Windows,Android,IOS等)都支持 多任务【同时运行多个任务】

操作系统的任务调度采用的是 时间片轮转 的抢占式调度【一个任务执行一段时间后被强制暂停去执行下一个任务,每个任务轮流执行】

任务执行的一小段时间叫做 时间片段 ,人物正在执行的状态叫做 运行状态,任务执行一段时间之后被强制暂停,被暂停的任务就处于 就绪态,等待它的下一个 时间片 的到来

这样每个人物都能执行到,由于CPU的效率非常高,时间片段非常短,在各个人物之间快速切换给人感觉就是多个任务 “同时进行”,这也就是我们所说的 并发

1.6 并发与并行

现在,几乎所有的电脑的CPU都是多核的,由于任务数量远远多于核心数。所以操作系统会把很多任务轮流安排在每个核心上去执行。

  • 并发:多个进程在一CPU上通过 时间片轮转 的方式,在一段时间内,让多个进程都得以推进。
  • 并行:多个CPU独立执行各自的一个进程,同时并行运行

操作系统把并发和并行明显区分开主要是从微观角度来说:具体是指进程的并行性【多处理机的情况下,并行运行】;并发性【但处理机的情况下,多个进程同一时间间隔运行】

并发与并行类似于工厂的流水线,可以从上面剧的例子得知:要扩大产量,1:扩建一个新的工厂,这就是并行;2:考虑每个工厂新增一个流水线,这就是并发

1.7 内核态与用户态

一般的操作系统会对执行权限进行分级:用户态内核态【在开篇的图中】

  • 内核态(核心态,管态):操作系统内核作为调用底层硬件的底层软件,权限最高
  • 用户态:权限最低,运行在内核态之上,通过 用户接口程序(shell或者GUI:Graphical User Interface)【这一层处于用户态】允许运行其他程序,如QQ,浏览器,网易云。

而且越靠近用户态的程序越容易编写,如果不喜欢某个浏览器自己可以重新写一个或者换一个;但是不能自行写一个操作系统或者是中断处理程序。

原因是:这个程序由硬件保护,防止外部对其进行修改

1.8 进程中的上下文

上下文简单来说就是某软件运行时所需要的环境,进程在 时间片轮转切换 时,由于每个进程运行所需要的环境不一样,就涉及到转换前后的上下文环境的切换。【QQ 和 迅雷在运行时候需要的环境不一样,后台进程中时间片轮转到QQ,进程中上下文就需要切换恢复到QQ运行停止前所需要的运行环境以供QQ的运行;时间片轮转到迅雷,进程中上下文就需要切换恢复到迅雷云行停止前的环境以供迅雷的运行】

  • 保存的是什么:一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈上的内容。
  • 切换过程中干了什么:切换时需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能 够恢复切换时的状态,继续执行。

1.9 进程状态

  1. 就绪:进程处于可运行状态,CPU时间片轮转还没有轮到该进程
  2. 运行:进程处于可运行状态,切CPU时间片轮转到该进程
  3. 阻塞:进程不具备运行条件,等待某个事件的完成
  4. 锁池状态:进程被加了锁,不能由之前那样抢占式调度执行,需要按顺序执行
  5. 等待队列:进程需要等待某个进程运行完毕之后再执行

1.10 多线程的生命周期概览

1.11 小结

  1. 线程是在进程里面的
  2. 每个线程都是一个独立的执行流(可以独自执行一段自己的代码)
  3. 同一个进程的每个线程之间,共用同一份资源(虚拟地址空间+文件描述表)
  4. 从操作系统的角度来看:进程是资源分配的基本单位,线程是CPU调度执行的基本单位

2. 线程

2.1 启动一个线程

2.1.1 Thread用法一

创建一个子类,继承自 Thread,重写 Thread 中的 run 方法。

这个 run 方法内部包含了这个线程要执行的代码(每个线程都是一个独立的执行流)当线程能跑起来了,就会以此来执行这个 run 方法中的代码

package thread
/*
标准库里提供了一个 Thread 类,使用这个类来表示线程
这个类的使用方式有很多种定义了这个类,就描述我们准备创建一个什么样的线程,但是创建这个类并没有真正的创建出线程*/
class MyThread1 extends Thread {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);/*防止打印太快sleep:休眠【在休眠过程中,休息 1000ms(1s),在 1000ms 之内这个线程不会占用 CPU,不会继续执行命令】*/} catch (InterruptedException e) {e.printStackTrace();}}}
}public class Demo1 {public static void main(String[] args) {// 这是主线程// 1.创建 Thread 实例,此处创建的是 MyThread1MyThread1 t = new MyThread1();/*2.调用 Thread 的 start 方法,才才真正在系统内部创建线程main 自身,也是通过一个线程来执行的一个进程里面不可能一个线程也没有,至少得有一个线程,main方法其实就对应的了一个线程现在通过 t.start() 有创建了一个新的线程*/t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}/*
观察打印结果:
hello thread
hello main
hello main
hello thread
hello main
hello thread
hello main
hello thread
hello main
hello thread结论:多个线程之间执行的先后顺序,并不是完全确定,当 1s 时间到了之后,到底系统先唤醒那个线程,不确定的(取决于操作系统内部调度代码的具体实现)如果多个线程之间没有手动控制先后执行顺序。这个时候认为多个线程之间执行是 "随机顺序" 的【多线程的万恶之源】*/

我们查看一下java线程的运行状态

Mac用户可以直接使用命令后打开;Windows电脑需要在JDK的bin目录下,有一个jconsole.exe。运行它就可以打开Java程序的见识和管理控制台

创建这个线程的文件名是:Demo1.class


这个主要用来排查 死锁 问题的。

状态:类似于进程中的状态。TIMED-WARITING这个状态就是一种 “阻塞” 状态,(睡眠的状态,是由 sleep 方法引起的)

堆栈跟踪:当前线程的代码执行到哪儿了,对应的调用栈是啥。就会显示jconsole读取 java 进程一瞬间当时的状态

2.1.2 Thread 用法二

  1. 创建一个类,实现 Runnable 接口(也是标准库自带的一个接口),并重写 run 方法

  2. 创建 Thread 实例,然后把刚才实现 Runnable 接口的实例给设置进去

package thread;class MyThread2 implements Runnable {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}public class Demo2 {public static void main(String[] args) {Thread thread = new Thread(new MyThread2());thread.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

方案1:是通过继承 Thread 类实现的

方案2:通过实现 Runnable 实现的(通过 Runnable 这种方式,相当于把 “要执行的任务” 和 Thread类 进行分离(解耦合))

2.1.3 匿名内部类继承Thread创建

package thread;public class Demo3 {public static void main(String[] args) {Thread t = new Thread() {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}};t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

2.1.4 匿名内部类实现Runnable

package thread;public class Demo4 {public static void main(String[] args) {Thread t = new Thread(new Runnable() {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}});t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

2.1.5 搭配lambda表达式

package thread;public class Demo5 {public static void main(String[] args) {Thread t = new Thread(() -> {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

2.1.6 调用 run 和调用 start区别

package thread;public class Demo6 {public static void main(String[] args) {Thread t = new Thread() {@Overridepublic void run() {while (true) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}};
//        t.run();t.start();while (true) {System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}

start:创建了新的线程,并在新线程中执行 run 方法

run:自身不具备创建线程的能力,仍然在旧的线程中执行【打开jconsole也可以明显看到没有Thread-0】


现在我们会启动一个线程了,但是到底有什么作用呢?

2.1.7 线程效率的简单对比

并发编程(多线程)最明显的优势就是针对 CPU密集型 的程序,能够提高效率。程序需要进行大量运算。

package thread;public class Demo7 {// 1.串行执行public static void serial() {// 两个整数反复进行自增long start = System.currentTimeMillis();long i = 0, j = 0;while (i++ < 10_0000_0000) ;while (j++ < 10_0000_0000) ;long last = System.currentTimeMillis();System.out.println("serial`s time:" + (last - start));}// 2.并发执行public static void concurrency() throws InterruptedException {long start = System.currentTimeMillis();// 这里的计时:要记录执行最慢的时间(整体执行结束即可)Thread t1 = new Thread(() -> {long i = 0;while (i++ < 10_0000_0000) ;});Thread t2 = new Thread(() -> {long i = 0;while (i++ < 10_0000_0000) ;});t1.start();t2.start();/*不能直接 last-start:因为当前 concurrency 和 t1,t2 之间是并发执行关系t1.join():就是等待t1执行完了在执行下边的代码*/t1.join();t2.join();long last = System.currentTimeMillis();System.out.println("concurrency`s time:" + (last - start));}public static void main(String[] args) throws InterruptedException {serial();concurrency();}
}// 输出结果
serial`s time:1759
concurrency`s time:900

根据运行结果:未使用多线程之前,消耗的时间1759ms;使用多线程之后,消耗的时间是900ms。

这里的提升很明显,但也并不是每次提升都是减少时间的50%。毕竟系统中不能保证这俩线程是完全并行执行的(尤其是系统资源本身比较紧张的情况下)

2.2 Thread类及常见方法

Thread 类是 JVM 用来管理线程的一个类,换句话说:每一个线程都有一个唯一的 Thread对象与之关联。

2.2.1 Thread类常见的构造方法

Thread t1 = new Thread();Thread t2 = new Thread("t2");Thread t3 = new Thread(new Runnable() {@Overridepublic void run() {}
});Thread t4 = new Thread(new Runnable() {@Overridepublic void run() {}
}, "t4");

2.2.2 Thread类常见的几种属性

属性 方法
ID:是线程的唯一标示,不同线程不会重复 getID( )
名称:各种调试工具用到 getName( )
状态:表示线程当前所处的情况 getStatus( )
优先级:优先级高的理论上更容易被挂载在CPU上 getPriority( )
是否为后台程序:JVM会在一个进程的所有非后台线程结束后,才会结束运行 isDaemon( )
是否存活:简单的理解为就是run方法是否结束了 isAlive( )
是否被中断 isInterrupted( )

什么时候进程真正退出

如果我们创建出来的一个线程是一恶搞非后台的线程,此时即使 main 方法执行完毕,java进程仍然要继续执行,等到所有的非后台线程执行完,java进程才结束

如果一个线程是后台线程,此时,后台线程就不会影响到 java 进程的结果

合适真正创建出线程

Thread t 这个变量 和 操作系统内核中的线程,生命周期不是完全相同的,t 被创建了,内核里不一定有对应的线程,需要 t.start() 调用之后才会有对应的线程

何时被真正的销毁

内核中的线程被销毁了(执行结束了),t 不一定销毁

内河线程的 run 方法执行完毕也就是结束了,t 的话要等到 GC(垃圾回收机制)来进行释放

2.3 控制线程的几种方法

2.3.1 创建线程

start 方法,在操作系统内部创建出一个新的线程

start 才是真正驱使操作系统创建出新的线程

run 只是描述了线程要执行的任务,只是一个普通的方法

在 Thread 的子类中重写的 run 方法就会被 start 里面创建的心线程来执行【start 内部调用操作系统提供的 api 创建线程就会让线程执行 run 方法中的代码】

2.3.2 中断线程

如果线程的 run 方法执行完了,线程就随之结束了

  1. 可以手动设置一个标志位,作为循环的判定条件

    package thread;public class Demo8 {// 通过这个变量来控制线程是否结束private static boolean isQuit = false;public static void main(String[] args) {Thread t = new Thread(() -> {while (!isQuit) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();// 在主线程内控制 t 线程3s后退出try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}isQuit = true;}
    }
    

    这里的 isQuit 这个变量相当于在 t 线程中读取,在 main 中修改。如果按照上述代码来写,其实存在一定的问题

  2. 可以借助 Thread 实例中自己提供的一个标志位

    package thread;public class Demo8 {public static void main(String[] args) {Thread t=new Thread(){@Overridepublic void run() {while (!this.isInterrupted()){System.out.println("helllo thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}};t.start();// 3s后 t 线程结束try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}t.interrupt();}
    }
    

    在 t 线程代码中,存在两种情况

    1. 执行打印和while循环判定(线程处于就绪态)
    2. 进行sleep(线程处于阻塞状态/休眠状态)

    之所以线程没有被中断,是因为 t 线程中的catch语句里面没有中断的代码,当前的catch语句直接把 interupt 给忽略了

    interupt这个方法,说是中断线程,但是并不是直接立即马上的杀死线程。具体线程怎么退出的,还是线程代码自己说了算

如何使得标志位 interupt 立即生效呢?

其实很简单,上述也说明了线程没有退出的原因。既然是 while 循环,那么我们使用一个 break 添加在 catch 就可以了。

package thread;public class Demo9 {public static void main(String[] args) {Thread t=new Thread(){@Overridepublic void run() {while (!this.isInterrupted()){System.out.println("helllo thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();/*在 break 之前,还可以完成一些代码流程TODO...*/break;}}}};t.start();// 3s后 t 线程结束try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}t.interrupt();}
}

2.3.3 等待一个线程

等待指定的线程执行完毕,比如:在 main 线程中调用 join(),就是让 main 等待 t 执行完毕

当前调用 join() 的时候,join 方法就会阻塞【效果和 sleep() 类似】

代码执行到 join 就不再继续往下走了,回进入等待,等到 t 线程执行完(t 的 run 方法结束了)然后 join 才能继续执行

通过 join 我们也可以控制线程执行的先后顺序

package thread;public class Demo10 {public static void main(String[] args) {Thread t = new Thread(() -> {for (int i = 0; i < 3; i++) {System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();System.out.println("t 线程尚未结束");try {t.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("t 线程已结束");}
}/*
打印结果t 线程尚未结束
hello thread
hello thread
hello thread
t 线程已结束
*/

这里的 join 是不带参数的版本

也就是死等。今天约的女神出来喝奶茶,到了女神楼下发现女神没下楼,就等到她下楼为止。

无参数调用的是 join(0),我们继续深究一下

设置了一个超时时间,女神30分钟后没出来,我就不等女神了。

在看一下带有两个参数版本的 join

2.3.4 线程的引用

在某个线程的代码中,拿到当前这个线程对应的 Thread 对象的引用才能做一些后续操作,很多喝线程相关的操作,都是一句这样的引用。

  1. 如果继承 Thread 类来创建线程,此时直接在 run 方法中通过 this 就能拿到线程的实例
  2. 更常见的是使用 Thread 里面的一个静态方法,currentThread() 哪个线程调用了这个静态方法就能够返回哪个线程对应的 Thread 实例引用
package thread;public class Demo11 {public static void main(String[] args) {Thread t=new Thread(new Runnable() {@Overridepublic void run() {//                while (this.isInterrupted()){//                    /*
//                    在这个代码中,通过 this.isInterrupted() 方法是不能直接调用的,仔细一看。当前 run 不是 Thread 的方法,而是 Runnable 的方法
//                    因此 this 也就只想的是 Runnable~当然没有 Thread 类的属性和方法
//                     */
//                }while (!Thread.currentThread().isInterrupted()){/*此时的 run 方法虽然仍然是 Runnable 方法但是是通过这个 Thread 的 currentThread 来获取线程实例的Thread.currentThread() 哪个线程在调用,就返回哪个线程的实例*/}}});}
}

2.3.5 线程的休眠

sleep方法

调用 sleep(times) 的线程会阻塞等待,取决于 times 指定的时间

进程管理,PCB 来描述一个进程。使用双向链表来阻止这些 PCB【这个说法针对的是:一个进程中只有一个线程的情况。更多是的一个进程中有很多个线程,每个线程都对应一个 PCB,此是一个进程对应了一组 PCB,操作系统是以 PCB 为单位进行调度执行的】

就绪状态的链表(就绪队列)【用链表做的队列】

pid是进程的id(更准确的说法是:pcbid);tgrpid是线程组id(更应该叫做进程id)

tgrpid相同的同属于同一个进程

系统中还有很多个这样链表对列

既然有就绪对列,与之对应的就是阻塞对列

操作系统调度 PCB 的时候,就是从 就绪对列 中挑出一个 PCB 去 CPU 上执行;当我们调用sleep 方法的时候,线程就从就绪对列进入阻塞对列

阻塞对里的 PCB 是不会被系统调度到 CPU 上执行。必须要等到条件时机成熟了,才会被放在就绪对列挂载在 CPU 上执行。

写了sleep(1000),何时被CPU执行呢?

1000ms之后,PCB 1003 就回到了就绪对列,但是这个线程什么时候会被CPU执行,可能是10001ms,也可能是1005ms…这个不确定(操作系统调度线程是存在一定的随机性)

2.4 线程状态

  1. NEW:安排了工作,但还没有开始【把 Thread对象 创建出来了,但是 内核里的线程还没有创建(未调用 start( ) )】
  2. TERMINATED:工作完成了【内核里的线程已经结束了,然后 Thread 对象还在】
  3. RUNNABLE:可工作的,又可以分为正在工作中和即将开始工作【就绪状态】
  4. TIMED_WAITING:排队等待其它事情
  5. BLOCKED:排队等待其它事情
  6. WAITING:排队等待其它事情
package thread;public class Demo12 {// 1.打印所有线程状态private static void showAllState() {for (Thread.State state : Thread.State.values()) {System.out.println(state);}}// 2.NEWprivate static void State_NEW() {Thread t = new Thread(() -> {});System.out.println(t.getState());}// 3.TERMINATEDprivate static void State_TERMINATED() {Thread t = new Thread(() -> {});t.start();try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(t.getState());}// 4.RUNNABLEprivate static void State_RUNNABLE() {Thread t = new Thread(() -> {while (true) {}});t.start();try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(t.getState());}// 5.TIMED_WAITINGprivate static void State_TIMED_WAITING() {Thread t = new Thread(() -> {while (true) {try {Thread.sleep(500);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(t.getState());}// 6.BLOCKEDprivate static void State_BLOCKED() {Object locker = new Object();Thread t = new Thread(() -> {while (true) {synchronized (locker) {}}});}// 7.WAITINGprivate static void State_WAITING(){Thread t=new Thread(()->{});}public static void main(String[] args) {//        showAllState();
//        State_NEW();
//        State_TERMINATED();
//        State_RUNNABLE();
//        State_TIMED_WAITING();
//        State_BLOCKED();
//        State_WAITING();}
}

3. 线程不安全

3.1 线程不安全经典的案例

【两个线程对同一个变量进行++操作】

package thread;class Counter {public int count = 0;public void increase() {++count;}
}public class Demo13 {private static Counter counter = new Counter();public static void main(String[] args) {// 创建两个线程,分别对 counter 调用 5w次 increase 操作Thread t1 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(counter.count);}
}// 输出结果:
52742

为什么呢?

我们先看看一般情况下 count=0,count++ 的步骤

线程安全计算的过程

load:CPU读内存

add:寄存器计算

save:写内存

如果是下面的方式运行就会出现线程不安全

两个线程的 add,只有一个 add 效果

Tips:

我们发现,只要线程中出现 数据抢占读写 就会有线程不安全的极大概率发生。因为操作系统对于线程的调度具有随机性,因此多个线程运行的时候,load,add,save 的执行顺序都是不稳定的

以下是加了线程不安全,加了synchronized 之后运行图

3.2 线程不安全原因

3.2.1 线程的抢占式运行

根本原因:操作系统决定的,这个无法更改

3.2.2 两个线程在修改同一个变量

这种操作是不安全的

1个线程修改1个变量【安全】

2个线程读1个变量【安全】

2个线程修改2个变量【安全】

针对这个原因,可以进行一定程度的处理

调整代码结构,避免出现这种多个线程修改一个变量的情况(有些场景中可以进行这样的调整)

3.2.3 线程针对变量的修改操作不是原子的

加锁(synchronized)就是把:这些不是原子操作的打包在一起。【++:load,add,save】

普遍性最高的办法也是处理线程安全问题最典型的办法

3.2.4 内存可见性

package thread;import java.util.Scanner;/*
场景:有一个变量,一个线程循环快速的读取这个变量的值,另一个线程会在一定时间之后修改这个变量
内存可见性:A线程修改了内存,B线程看不见
*/
public class Demo14 {private static int isQuit = 0;public static void main(String[] args) {Thread t = new Thread(() -> {while (isQuit == 0) {// 循环里面什么都不写}System.out.println("t 线程结束999999");});t.start();// 在主线程中通过 Scaner 让用户输入一个值并赋值给 isQUit,从而影响 t 线程退出Scanner sc = new Scanner(System.in);System.out.print("请输入一个整数:");isQuit = sc.nextInt();System.out.println("main 线程结束");}
}

在这个代码中,就看到了 main 线程中修改了 isQuit,但是 t 线程并没有随之退出

原因什么呢?

原因在于 Java 中编译器的优化,程序员写的代码。Java 编译器在编译的时候,并不是原封不动的逐字翻译。而是会在保证原有逻辑不变的情况下,动态调整要执行的指令内容,提高程序运行的效率

我们以为的运行步骤

编译器优化后的步骤

优化后步骤的解释:

再多线程环境下,isQuit 的值是可能会发生改变的,但是编译器在进行优化的时候,没法对于多线程代码做过多的预判,只是看到了 t线程 内部没有地方去修改 isQuit,并且这个 t线程 内反复执行了太多次 load 了

load:是读内存,从内存中读取数据比要从寄存器中读取数据慢了 3~4个数量级

在 t线程 中,编译器的感受就是反复进行了太多次的 load 操作,太慢了;同时 load 得到的结果好像还是一直不变的,因此编译器做了一个大胆的决定:直接省略掉 load ,只保留一次,后序的操作不再重新读内存而是直接从寄存器中读。main线程修改了 内存中的变量isQuit,但是由于 t线程,读取的是 寄存器中的变量isQuit,所以并不会结束 t线程

好了,到此 内存可见性 的线程不安全也知道了之后,那该如何解决呢?

  1. 老生常谈的 synchronized加锁
  2. 还可以使用另外一个关键字 volatile
package thread;import java.util.Scanner;/*
场景:有一个变量,一个线程循环快速的读取这个变量的值,另一个线程会在一定时间之后修改这个变量
*/
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();// 在主线程中通过 Scaner 让用户输入一个值并赋值给 isQUit,从而影响 t 线程退出Scanner sc = new Scanner(System.in);System.out.print("请输入一个整数:");isQuit = sc.nextInt();System.out.println("main 线程结束");}
}

synchronized就不做过多解释,这里出现了一个新的关键字:volatile

volatile保证编译器在进行优化的时候,就知道了禁止进行上述的 读内存优化,会保证每次都重新读内存,哪怕速度会慢一点

  1. 指令重排序

    这个也是编译器有害的。触发指令重排序的前提也要保证代码的基本逻辑不变。这样也会提升运行效率。

    如果是单线程环境下,这里的判定是比较准的。

    如果是多线程下,这里的判定就不一定了。

    通过 synchronized 同样也能解决上述问题,编译器对于被 synchronized 内部的代码是非常谨慎的,不会随便乱优化

4. 线程不安全解决方案

4.1 最典型的方案就是加锁

通过加锁操作,就可以把上述的 “随机操作” 变成 “有序操作”,而这个加锁就是加 synchronized

package thread;class Counter {public int count = 0;synchronized public void increase() {++count;}
}public class Demo14 {private static Counter counter = new Counter();public static void main(String[] args) {// 创建两个线程,分别对 counter 调用 5w次 increase 操作Thread t1 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(counter.count);}
}

t1 先拿到了锁,t2来访问的时候由于已经被t1锁住,所以无法访问【t2就是BLOCK状态】,只有等t1解锁之后,t2才能访问。

还记得当初为什么引入 多线程 吗?:目的就是为了 并发编程,当加了锁之后,数据结果就对了,但是这里的并发性其实就降低了,速度也就慢了

synchronized 还有其它用法吗?

肯定有的,放在不同地方,锁机制就会不同。

4.2 synchronized的使用

4.2.1 直接接到一个普通方法上

进入方法就相当于加锁,除了方法就相当于解锁【这个就是最典型的加锁方案】

package thread;class Counter {public int count = 0;// synchronized 加在普通方法上synchronized public void increase() {++count;}
}public class Demo14 {private static Counter counter = new Counter();public static void main(String[] args) {Thread t1 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(counter.count);}
}// 运行结果
100000

4.2.2 加到一个代码块上

需要手动的制定一个 “锁对象”

package thread;class Counter {public int count = 0;// synchronized 加在代码块上public void increase() {synchronized (this) {++count;}}
}public class Demo14 {private static Counter counter = new Counter();public static void main(String[] args) {Thread t1 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(counter.count);}
}// 运行结果
100000

像这样的代码,就是通过手动置顶锁对象为 this

也可以指定任意对象作为锁对象,在 Java 中,任何一个继承自 Object 的类对象都可以作为锁对象(synchronized 加锁操作,本质上是在操作 Object 对象头中的一个标志位)

4.2.3 加到一个 static 类对象上

此时相当于指定了当前的类对象为锁对象

两个线程竞争一把锁

package thread;import java.util.Scanner;public class Demo16 {private static Object locker1 = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {Scanner scanner = new Scanner(System.in);while (true) {synchronized (locker1) {// 获取到锁之后,就让程序阻塞,通过 Scanner 来进行阻塞System.out.print("请输入一个整数");int a = scanner.nextInt();System.out.println("a:" + a);}}});t1.start();Thread.sleep(1000);// 为了保证 t1 现运行(先拿到锁),然后 t2 再运行Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);while (true) {synchronized (locker1) {System.out.println("t2");}}});t2.start();}
}

两个线程两把锁

package thread;import java.util.Scanner;public class Demo16 {// 任意类对象都可以作为锁对象private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {Scanner scanner = new Scanner(System.in);while (true) {synchronized (locker1) {// 获取到锁之后,就让程序阻塞,通过 Scanner 来进行阻塞System.out.print("请输入一个整数");int a = scanner.nextInt();System.out.println("a:" + a);}}});t1.start();Thread.sleep(1000);// 为了保证 t1 现运行(先拿到锁),然后 t2 再运行Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);while (true) {synchronized (locker2) {System.out.println("t2");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}});t2.start();}
}

4.2.4 可重入锁

操作系统原声提供的加锁相关的 api就是不可重入锁

可重入锁是 synchronized 一个重要特性。

如果 synchronized 不是可重入的,那么很容出现 “死锁” 现象

package thread;class Counter {public int count = 0;// synchronized 可重入锁synchronized public void increase(){synchronized (this){++count;}}
}public class Demo14 {private static Counter counter = new Counter();public static void main(String[] args) {Thread t1 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 5_0000; i++) {counter.increase();}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(counter.count);}
}// 运行结果
100000

如果针对同一个对象(此处是this,也就是 counter 对象),加锁两次,并且如果锁不是可重入的,就会出现 死锁

  1. 当调用 increase 的时候,先进性加锁操作【针对 this,就是锁定的状态了,把 this,对象头中的标志位给设置上了】
  2. 继续执行到下面的代码块的时候也会尝试再次加锁【由于此时 this 已经是锁定状态了,按照之前的知识,这里应该会出现 阻塞】

这里的阻塞持续到什么时候结束呢?等到之前代码把锁释放

可问题又来了:要执行完整个方法,外面的锁才能释放,但是里面的锁处于阻塞状态又让整个方法无法运行【僵住了】

代码的运行结果真的如我们猜测的一样吗?

结果并不是的,两个线程各累加5w次,可重入的结果是10w

Java 设计锁的大佬就考虑到了这个情况,于是就把当前这里的 synchronized 设计成可重入锁了,针对同一个锁,连续加锁多次不会有负面效果

锁中持有两个信息:

  1. 当前这个锁被哪个线程给持有
  2. 当前这个锁被加锁了几次

加锁步骤解析:

当线程t已经加锁成功后。后续再次尝试加锁,就会自动的判定出当前这把锁就是 t 持有的;第二次加锁不会真的 “加锁” ,而只是进行一个修改奇数(1->2)

后续往下执行的时候,出了 synchronized 代码块,就触发一次解锁(也不是真的解锁,而是记数-1)

在外层方法执行完了之后,再次记数-1,记数减成 0 了,才是真正的进行解锁。

死锁出现的情况,不仅仅是上述这一种情况(针对同一把锁,连续加锁两次)

  1. 一个线程一把锁
  2. 两个线程两把锁【两个线程各自需要对方线程的资源,但是对方线程资源都上了锁】
  3. N个线程M把锁【哲学家就餐问题】
线程不安全 线程安全
ArrayList Vector(不推荐使用)
LinkedList HashTable(不推荐使用)
HashMap ConcurrentHashMap
TreeMap StringBuffer
HashSet String(比较特殊,因为是不可变对象,因此就不能再多线程中修改同一个String了)
TreeSet
StringBuilder
package thread;import java.util.Scanner;public class Demo16 {private static 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 scanner = new Scanner(System.in);System.out.print("请输入一个整数:");isQuit = scanner.nextInt();System.out.println("main 线程结束");}
}// 运行结果
请输入一个整数:1
main 线程结束
t 线程结束

4.3 wait和notify搭配

多线程程序由于系统的随机调度,在系统层面上无法解决(因为无法修改系统源码)。但我们也可以通过一些特殊手段来对线程之间执行的顺序做出一定的控制。

wait等待和notify唤醒

当某个线程调用了 wait 之后,就会阻碍等待,直到其它某个线程调用 notify 把这个线程唤醒为止。

t1和t2线程,希望先执行t1,再执行t2。就让t2进行 wait,当t1执行完毕之后调用 notify 唤醒 t2

package thread;public class Demo17 {public static void main(String[] args) throws InterruptedException {Object o=new Object();System.out.println("等待之前");/*当 main 线程执行到 wait 的时候,就会陷入阻塞阻塞到其它线程唤醒 main 线程,当前代码中没有别的线程唤醒,因此就会一直阻塞*/o.wait();System.out.println("等待之后");}
}//运行结果
等待之前
Exception in thread "main" java.lang.IllegalMonitorStateExceptionat java.lang.Object.wait(Native Method)at java.lang.Object.wait(Object.java:502)at thread.Demo17.main(Demo17.java:11)

Monitor:指的是 synchronized(也叫做监视器锁)

针对一个没有加锁的对象进行解锁动作,就会出现上述异常

wait这个方法会做三件事情:

  1. 先针对 o 解锁
  2. 进行等待(等待通知的到来)
  3. 当通知到来的时候就会被唤醒,同时尝试重新获取锁,然后再继续执行(正因为做了这几件事,所以 wait 才需要搭配 synchronized 来使用)

notify 也是 Object 类的方法,哪个对象调用 wait 就需要哪个对象调用 notify 来唤醒

notify 同样也要搭配 synchronized 来使用,如果多个线程都在等待,调用一次 notify,只能唤醒其中的一个线程,具体唤醒的是谁,并不确定(随机的);如果没有任何线程等待,直接调用 notify 不会有副作用。

notifyAll:一次性唤醒全部,然后再竞争同一个锁(按照竞争成功的顺序依次往下执行)

package thread;public class Demo18 {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();}
}
// 运行结果

5. 单列和工厂设计模式

为何诞生出这些设计模式?

各行各业,每个人的专业水平参差不齐。业内大佬们根据一些常见的情景需求,制定了一些对应的解决方案【设计模式】供其它开发人员使用

5.1 单列模式

思考:什么是单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

注意:

  1. 单例类只能有一个实例
  2. 单例类必须自己创建自己的唯一实例
  3. 单例类必须提供一个 getter方法 供其它对象访问,否则无法使用

创建单例模式我们可以通过两种模式来创建:1.饿汉模式,2.懒汉模式

就以经常用到的 JDBC 伪代码示例,因为数据库的连接只需要一份就够了。因此适合单例模式

public class DBUtil {// 1.设置数据库位置信息private static String URL = "jdbc:mysql://localhost:3306/java101BookManage?characterEncoding=utf8&useSSL=false";private static String User = "root";private static String Password = "0x11223344";private static DataSource dataSource = new MysqlDataSource();static {// 静态代码块((MysqlDataSource) dataSource).setURL(URL);((MysqlDataSource) dataSource).setUser(User);((MysqlDataSource) dataSource).setPassword(Password);}// 2.链接数据库static Connection getConnection() throws SQLException {return dataSource.getConnection();}// 3. 释放资源: 这里的参数不为 null, 就进行关闭, 关闭顺序和打开顺序相反static void close(Connection connection, PreparedStatement statement, ResultSet resultSet) {//TODO 只在外面套一个 try catch 会出现问题: 如果其中某个报错, 就会让后边的无法关闭. 单个写 try catch 可以保证每个资源链接都可以关闭if (resultSet != null) {try {resultSet.close();} catch (SQLException e) {e.printStackTrace();}}if (statement != null) {try {statement.close();} catch (SQLException e) {e.printStackTrace();}}if (connection != null) {try {connection.close();} catch (SQLException e) {e.printStackTrace();}}}public DBUtil() throws SQLException {}
}

5.1.1 饿汉模式

package thread;// 实现一个单例模式的类
class SingletonSource1 {private static SingletonSource1 instance = new SingletonSource1();// 构造方法直接位 private:外卖呢类无法调用该类的构造方法(无法创建实例)private SingletonSource1() {}// 外界访问的唯一方法public static SingletonSource1 getInstance() {return instance;}
}public class Demo19 {public static void main(String[] args) {// 无论在代码的哪一个地方调用这里的 getInstance 得到的都是同一个实例SingletonSource1 source1 = SingletonSource1.getInstance();}
}

此处设个 static 成员的初始化时机,是在 “类加载” 的时候,程序启动之后,用到了这个类,就会立即加载。

实例创建的实际比较早。

5.1.2 懒汉模式

class SingletonSource2 {private static SingletonSource2 instance = null;private SingletonSource2() {}public static SingletonSource2 getInstance() {if (instance == null) {instance = new SingletonSource2();}return instance;}
}public class Demo19 {public static void main(String[] args) {SingletonSource2 source2 = SingletonSource2.getInstance();}
}

和饿汉模式相比:懒汉模式 主要的差别在于这个实例的创建时机不同了,不再是类加载的时候就立即创建实例,而是在首次调用到 getInstance 的时候,才会真正的创建实例

5.1.3 两种模式的对比

饿汉模式 和 懒汉模式再多线程环境下调用 getInstance 有什么安全问题吗?

对于饿汉模式来说:多线程调用 getInstance,只是在针对同一个变量来 “读”(线程安全的)

对于懒汉模式来说:多线程调用 getInstance,大部分情况下也是读但是也可能会修改, 修改发生在未初始化之前,多个线程同时调用 getInstance,就可能导致多线程同时修改

饿汉模式 懒汉模式
加载方式 一次性全部加载 用一个加载一个
线程安全 一次性全部加载,因此是安全的 当有10G实例需要加载的时候,线程之间有的是null有的是新创建对象就会影响对象,因此是不安全的

更推荐使用 懒汉模式

5.1.4 如何解决懒汉模式的线程安全问题

加锁:把读写操作打包成一个原子的操作

class SingletonSource2 {private static SingletonSource2 instance = null;private SingletonSource2() {}public static SingletonSource2 getInstance() {synchronized (SingletonSource2.class) {if (instance == null) {instance = new SingletonSource2();}}return instance;}
}

发现这个代码的确解决了线程安全问题,但是带来的效果就是线程竞争,效率会很慢

对于懒汉模式来说,线程不安全知识出现在未初始化的时候,(instance==null)。一旦要是初始化好了,后续再使用 getInstance,都会触发这里的竞争。哪怕是 instance 实例化完毕,已经没有线程安全问题,但是仍然会触发锁竞争

进一步的说就是:这个锁需要我们在实例化之前加,实例化之后不加锁。再加一个 if 判断即可

class SingletonSource2 {private static SingletonSource2 instance = null;private SingletonSource2() {}public static SingletonSource2 getInstance() {if (instance == null) {synchronized (SingletonSource2.class) {if (instance == null) {instance = new SingletonSource2();}}}return instance;}
}

发现了个神奇的事情:if 判断条件一模一样,并且两个 if 又是相同的。看起来相同,其实执行起来差别会很大。

主要及时中间的加锁会导致程序阻塞,这一阻塞就会导致效率急剧下降

虽然条件是加上了,但是还有个小问题

如果当前有很多个线程同时去调用 getInstace,就会涉及到很多个线程去读 instace(相当于是内存被CPU读取了很多次,就可能触编译器的优化,后续的读取操作可能就不真读了,而是直接读CPU寄存器【内存可见性】)

一旦后续有线程读了寄存器,此时之前的线程去修改 instance 的值,后续的心线程可能被编译器读取优化后感知不到。就会让后续的线程白白的多加一层锁

因此我们需要添加一个关键字:volatile

class SingletonSource2 {private static volatile SingletonSource2 instance = null;private SingletonSource2() {}public static SingletonSource2 getInstance() {if (instance == null) {synchronized (SingletonSource2.class) {if (instance == null) {instance = new SingletonSource2();}}}return instance;}
}

至此,线程安全的懒汉模式创建出的单例已经完善了

5.2 工厂模式「B站图灵课堂学习的,作为补充,所以叙述的不详细」

5.2.1 简单工厂模式

package thread;interface Product {// 在变化中找到稳定的部分public abstract void method1();
}class ProductA implements Product {public void method1() {System.out.println("ProductA.method()");}
}class ProductB implements Product {public void method1() {System.out.println("ProductB.method()");}
}class SimpleFactory {public static Product createProduct(String type) {if (type.equals("0")) {return new ProductA();} else if (type.equals("1")) {return new ProductB();} else {return null;}}
}class Application {private Product createProductA(String type) {// ... init// ...return new ProductA();}Product getObject(String type) {// ...return SimpleFactory.createProduct(type);}
}public class FactoryMethod {public static void main(String[] args) {Application app = new Application();Product product = app.getObject("1");product.method1();}
}

5.2.2 工厂方法模式

package thread;interface Product {// 在变化中找到稳定的部分public abstract void method1();
}class ProductA implements Product {public void method1() {System.out.println("ProductA.method()");}
}class ProductB implements Product {public void method1() {System.out.println("ProductB.method()");}
}class SimpleFactory {public static Product createProduct(String type) {if (type.equals("0")) {return new ProductA();} else if (type.equals("1")) {return new ProductB();} else {return null;}}
}abstract class Application {public abstract Product createProduct();Product getObject() {// ...Product product = createProduct();return product;}
}class ConcreateProductA extends Application {@Overridepublic Product createProduct() {// ...return new ProductA();}
}class ConcreateProductB extends Application {@Overridepublic Product createProduct() {// ...return new ProductB();}
}public class FactoryMethod {public static void main(String[] args) {Application app = new ConcreateProductB();Product product = app.getObject();product.method1();}
}

5.2.3 抽象工厂模式

package thread;/*
变化      MySQL,Oracleconnection,command*/
interface IConnection {public abstract void connect();
}interface ICommand {public abstract void command();
}interface IDatabaseUtils {public abstract IConnection getConnection();public abstract ICommand getCommand();
}// 以 MySQL 为例
class MysqlConnection implements IConnection {@Overridepublic void connect() {System.out.println("MySQL connections");}
}class OracleConnection implements IConnection{@Overridepublic void connect() {System.out.println("Oracle connections");}
}class MysqlCommand implements ICommand {@Overridepublic void command() {System.out.println("MySQL command");}
}class OracleComman implements ICommand{@Overridepublic void command() {System.out.println("Oracle command");}
}class MysqlDataBaseUtils implements IDatabaseUtils {@Overridepublic IConnection getConnection() {return new MysqlConnection();}@Overridepublic ICommand getCommand() {return new MysqlCommand();}
}class OracleDataBaseUtils implements IDatabaseUtils{@Overridepublic IConnection getConnection() {return new OracleConnection();}@Overridepublic ICommand getCommand() {return new OracleComman();}
}public class AbstractFactory {public static void main(String[] args) {IDatabaseUtils iDatabaseUtils = new OracleDataBaseUtils();IConnection connection = iDatabaseUtils.getConnection();connection.connect();ICommand command = iDatabaseUtils.getCommand();command.command();}
}

6. 阻塞队列

6.1 阻塞队列概念

队列:先进先出(最常见的队列)

阻塞队列属于一种比特殊的队列(也是遵守先进先出)

  1. 线程安全队列
  2. 带有 “阻塞功能” 具体来说,如果队列为空,尝试进行出队列就会阻塞。一直阻塞到队列不为空;如果队列满,尝试进行近队列就会阻塞,一直阻塞队列不为满为止

package thread;import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;// 阻塞队列 的使用
public class Demo20 {public static void main(String[] args) throws InterruptedException {/*BlockingQueue 提供了入队列,出队列的方法对于 入队列offer,出队列 poll而言入队列put,出队列 take 才具备阻塞功能*/BlockingQueue<String> queue = new LinkedBlockingQueue<>();queue.put("hello");String s = queue.take();System.out.println(s);}
}

6.2 生产者消费者模型

基于阻塞队列,就可以实现 生产者消费者模型,非常经典的代码(实际运用中是非常常用的一种代码写法)

什么是生产者消费者模型?

一种更好的让多线程搭配工作的实现方式

举个例子:包饺子

  1. 三个人一起包饺子,A,B,C各自都是先擀饺子皮,挖馅,包一个饺子(这种方案提高的效率有限)

    因为涉及到竞争 擀面杖,所以效率会有下降

  2. 三个人一起包饺子,A负责擀饺子皮,BC负责包

    要把擀好的饺子皮存放起来分发给BC使用

    生产者消费者模型起到的作用

    在开发中起到 解耦合 的效果

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-27w2IFg6-1650127071487)(/Users/cxf/Desktop/MarkDown/images/无生产者消费者.png)]

    没有生产者消费者模型,B服务器如果需要更改,会牵着到A服务器的更改。

    A遇到高并发的请求时候,也会把大量的请求发给B服务器,B服务器如果本省压力很大,则会可能出现挂掉的情况发生。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3fMYyjQ9-1650127071488)(/Users/cxf/Desktop/MarkDown/images/有生产者消费者.png)\

有了阻塞队列作为缓冲,可以应对特定情况下的高并发也能方便后续B服务器的更改。

package thread;import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;class MyBlockingQueue<T> {// 队列的大小private int capacity = 1_000;private int[] elem = new int[capacity];// 队列的元素个数private int size = 0;// 队首位置private int head = 0;// 队尾位置private int tail = 0;// 锁对象private Object locker = new Object();// 入队列public void put(int value) throws InterruptedException {synchronized (locker) {if (size == elem.length) {/*队列满:触发阻塞操作被谁唤醒:当队列不满的时候就要被唤醒【每次 take 成功】*/locker.wait();}elem[tail++] = value;if (tail >= elem.length) {tail = 0;}++size;// 唤醒 take阻塞locker.notify();}}// 出队列public Integer take() throws InterruptedException {synchronized (locker) {if (size == 0) {/*队列为空,再次 take 的时候就要阻塞被谁唤醒:当不为空的时候【每次 put 成功】*/locker.wait();}Integer ret = elem[head++];if (head >= elem.length) {head = 0;}--size;// 唤醒 put阻塞locker.notify();return ret;}}
}// 阻塞队列 的使用
public class Demo20 {public static void main(String[] args) throws InterruptedException {/*BlockingQueue 提供了入队列,出队列的方法对于 入队列offer,出队列 poll而言入队列put,出队列 take 才具备阻塞功能*/BlockingQueue<String> queue = new LinkedBlockingQueue<>();queue.put("hello");String s = queue.take();// 测试自己的阻塞队列是否运行正常MyBlockingQueue<Integer> myBlockingQueue = new MyBlockingQueue<>();
//        for (int i = 0; i < 5; i++) {//            myBlockingQueue.put(i);
//        }
//        for (int i = 0; i < 5; i++) {//            System.out.print(myBlockingQueue.take() + " ");
//        }// 使用阻塞队列作为 交易场所Thread productor = new Thread(() -> {int n = 1;while (true) {try {myBlockingQueue.put(n);System.out.println("生产者生产了:" + n);++n;// 给生产者加sleep【让生产慢,消费快】Thread.sleep(300);} catch (InterruptedException e) {e.printStackTrace();}}});productor.start();Thread customer = new Thread(() -> {while (true) {try {int n = myBlockingQueue.take();System.out.println("消费者消费了:" + n);// 给消费者加上 sleep【让生产快,消费慢】
//                    Thread.sleep(300);} catch (InterruptedException e) {e.printStackTrace();}}});customer.start();}
}

这里需要理解一下 阻塞队列 的背后数据结构对于以后有帮助

7. 定时器

日常工作中的一个非常常用的组件(尤其是在网络编程中),定时器就像一个 “闹钟”,设定让某个操作在一定时间之后执行

女神可能延迟很久回复,也有可能不回复…

定时器中是可以包含很多任务的,然后每个任务多长时间之后执行都是不确定的

  1. 描述(Task)

    描述任务具体要做什么工作(有一段代码)

    描述清楚任务什么时候被执行(记录一个任务的执行时间)【封装一个类,通过这个类,来表述当前的这个任务情况】

  2. 组织(堆)

    需要能把新的任务给加进来

    从这些任务中找出最快要到时间的任务

针对以上的描述,重点是 找出最快要到时间,因此。我们需要用到的是 堆,这样的数据结构

堆:完全二叉树。

小堆:堆顶元素(根结点)就是整个堆中的最小的元素

大堆:堆顶元素(根结点)就是整个堆中的最大的元素

  1. 定时器中还需要有一个单独的扫描线程

    这个线程就不需要不停的来扫描堆中的最小元素,看看当前这里的任务是不是已经到时间了。时间到了就执行这个任务。

    package thread;import java.util.Comparator;
    import java.util.Timer;
    import java.util.TimerTask;
    import java.util.concurrent.PriorityBlockingQueue;class MyTimer {static class Task implements Comparable<Task> {// 要执行的任务private Runnable runnable;// 什么时间去执行:此处的时间要使用绝对的时间戳来表示private long time;public Task(Runnable runnable, long time) {this.runnable = runnable;this.time = System.currentTimeMillis() + time;// 记录的是什么时间去执行这个任务}public void run() {runnable.run();}@Overridepublic int compareTo(Task o) {/*this-o:把时间小的放前面o-this:时间大的放前面*/return (int) (this.time - o.time);}}/*准备好 Task 任务之后,就可以利用 堆 来组织这样的任务PriorityQueue:本身是线程不安全的1.自己手动加锁2.PriorityBlockingQueue:带有优先级的阻塞队列,自己就是线程安全的*/private PriorityBlockingQueue<Task> tasks = new PriorityBlockingQueue<>();// 通过这个方法,往定时器中注册一个任务【告知定时器 after 毫秒之后要执行 runnable 中的 run 方法】public void schedule(Runnable runnable, long after) {Task task = new Task(runnable, after);tasks.put(task);}/*创建一个扫描线程。让这个扫描线程不停的取队首元素,判定任务是否可以执行MyTimer 实例化的时候就把这个线程给创建出来*//*由于 while 转得太快,虽然这个定时器也能完成任务,但是一直查询时间就失去了定时器的意义(在到达时间之前,让任务歇着把资源让给其它需要的地方,等时间到了再执行)为了解决反复看表的问题,加上一个 wait操作*/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);// 此处的 locker 存在的价值不是为了保证互斥而是为了能够进行等待synchronized (locker){locker.wait(task.time-curTime);}} else {task.run();}} catch (InterruptedException e) {e.printStackTrace();}}});t.start();}
    }public class Demo21 {public static void main(String[] args) {// 使用标准库的定时器Timer timer = new Timer();/*schedule:安排第一个参数:表示任务。任务就是 TimerTask 类,类似于 Runnable 接口,也是重写 run 方法第二个参数:延迟时间(毫秒)*/
    //        timer.schedule(new TimerTask() {//            @Override
    //            public void run() {//                System.out.println("Hello timer");
    //            }
    //        }, 1000);
    //        System.out.println("main");// 使用自己实现的定时器MyTimer myTimer = new MyTimer();myTimer.schedule(new Runnable() {@Overridepublic void run() {System.out.println("hello myTimer");}}, 1000);System.out.println("main");}
    }

    编写多线程代码的核心就是

    1. 需要消耗CPU资源的时候,最大化利用CPU
    2. 不需要消耗CPU的时候,尽可能的不消耗CPU

8. 线程池

使用多线程之后,如果还是频繁的创建/销毁线程确实比进程快多了。但是也顶不住大量频繁的消耗。因此就会用上线程池。

操作系统去创建销毁线程是一个成本比较高的事情;交给用户态来管理线程,就会成本低不少

因为内核态积压了很多任务待完成,所以什么时候轮到自己的任务被执行,是无法预知的。所以用户态的管理线程会比内核态高效许多。

发现这个 ThreadPoolExecutor类 参数很多也很复杂

假设一个公司有很多员工【临时工,正式工】

corePoolSize:核心线程数【正式工】

maximumPoolSize:最大线程数【正式工+临时工的最大数量】

keepAliveTime:允许临时工摸鱼的最大时间

TimeUnit unit:设置keepAliveTime的时间单位

BlockingQueue workQueue:可以手动指定一个任务队列,线程池里面也是要有一个队列来组织一大堆任务的【每个任务都用一个 Runnable 来表示的】

ThreadFactory threadFactory:线程池里的线程通过啥样的方式来创建的

RejectedExecutionHandler handler:如果任务队伍满了,新的任务该如何处理。比如:只忽略新的任务;也可以干掉最老的任务;也可以阻塞的等待

这样做的好处:

如果线程池比较空闲,当前池子中的线程数目就是正式员工的数量(没有临时工)。如果线程池比较忙了,当前池子中的线程树木就要增加一些,最大增加到最大员工数量(多雇佣一些临时工来帮忙)

再过一阵子线程池不忙了,就把实习生给裁掉,只保留核心线程

Java中的这个线程池,里面的线程是能够支持动态变化的。既可以适应负载高的情况,又可以在负载低的时候减少开销

ThreadPoolExecutor 使用过于复杂,标准库里提供了封装的版本:Executors

几个方法创建线程池的方法

  1. newFixedThreadPool:创建一个固定线程数的线程池
  2. newCachedThreadPool:线程池里面的线程数会动态发生改变
  3. newSingletonThread:创建了一个包含单个线程的线程池
  4. newScheduledThread:创建了一个类似于定时器的线程池,也是延时执行一个任务
package thread;import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;public class Demo22 {public static void main(String[] args) {// 此时创建好了一个线程池对象,这个就相当于 ThreadPoolExecutor 的封装版本ExecutorService pool = Executors.newFixedThreadPool(1);// 通过 submit 就可以把任务给添加到线程池中for (int i = 0; i < 100; i++) {pool.submit(new Runnable() {@Overridepublic void run() {System.out.println("hello");}});}}
}

线程数量多少合适呢?

实际应用中还是需要用到 ThreadPoolExecutor,这样跟据实际情况应用场景来设定更切合实际。

假设CPU是 4 核心

极端情况下,线程的任务 100% 的时间都在使用 CPU,此时线程数就不应该超过 4

极端情况下,线程的任务 1% 的时间在使用 CPU,99% 的时间都在阻塞,此时理论上线程数可设为 1000

实际上每个任务执行过程中,多少时间在使用 CPU 是不确定的,实践中是通过测试的方式来找到一个更适合的线程个数来设定【观察不同线程数的时候 CPU 的使用情况】

要做的保证就是:CPU既不会特别空闲,也不会特别紧张

线程数设得少,CPU就空闲,整个任务执行时间更长

线程数设的多,CPU 就烦忙了,整个任务执行的快。但是也不能让 CPU 太忙,要考虑到冗余,要能够预防线上的突发情况

手动实现一个线程池

package thread;import java.util.ArrayList;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;/*
线程池的实现
1. 描述一个任务,Runnable 即可
2. 如何去组织多个任务,此处使用的数据结构,就是一个普通的阻塞队列
3. 有一组线程来执行这里的任务(这样的线程称为 工作线程,不能只有一个)先描述出一个线程是啥样的
4. 使用一定的数据结构,把这若干个线程组织起来*/class MyThreadPool {// 1.描述任务// 2.使用阻塞队列,存放若干任务private ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1);// 3.还需要描述一个工作线程是啥样的static class Worker extends Thread {/*当前这里的 Worker 线程有好几个,这些线程要共同一个任务队列通过这里的构造方法,把我们上面创建好的任务队列传到线程里面,方便线程去取任务*/private BlockingQueue<Runnable> queue = null;public Worker(BlockingQueue<Runnable> queue) {this.queue = queue;}@Overridepublic void run() {// 一个线程要做的工作:反复的从队列中读取任务,然后执行while (true) {try {// 如果任务队列中不为空,此时就能立即取出一个任务并执行;如果任务队列为空,就会产生阻塞,阻塞到有人加入新的任务为止Runnable task = queue.take();task.run();} catch (InterruptedException e) {e.printStackTrace();}}}}// 4.需要组织若干个 工作线程private ArrayList<Worker> workList = new ArrayList<>();// 5.搞一个构造方法,制定多少个线程在线程池中public MyThreadPool(int n) {for (int i = 0; i < n; i++) {Worker worker = new Worker(queue);// 创建好的线程,先让它跑起来,再保存到数组中worker.start();workList.add(worker);}}// 6.实现一个 submit 来安排任务到线程池中public void submit(Runnable runnable) {try {queue.put(runnable);} catch (InterruptedException e) {e.printStackTrace();}}
}public class Demo22 {public static void main(String[] args) {// 此时创建好了一个线程池对象,这个就相当于 ThreadPoolExecutor 的封装版本ExecutorService pool = Executors.newFixedThreadPool(4);// 通过 submit 就可以把任务给添加到线程池中for (int i = 0; i < 8; i++) {pool.submit(new Runnable() {@Overridepublic void run() {System.out.println("hello");}});}// 启动手动的线程池MyThreadPool myThreadPool = new MyThreadPool(4);for (int i = 0; i < 8; i++) {myThreadPool.submit(new Runnable() {@Overridepublic void run() {System.out.println("MyThreadPool");}});}}
}

9.锁策略

9.1 悲观锁VS乐观锁

悲观锁:总是假设比较坏的情况,认为锁冲突是一个高概率事件,资源会被其它线程修改,所以当自己拿到这个资源的时候就会上锁,阻塞其它线程

乐观锁:总是假设比较乐观的情况,认为锁冲突是一个低概率事件,只有当检测发现冲突了,返回错误信息,并让用户处理

synchronized 开始时是一个乐观锁,竞争激烈就是一个悲观锁(可以根据实际应用场景,自适应)

当锁冲突概率比较低的时候,synchronized 内部就是以乐观锁的策略来实现的

当锁冲突概率比较高的时候,synchronized 内部就是以悲观锁的策略来实现的

对于悲观锁来说,如果出现冲突,最好的解决方案就是 阻塞等待;对于乐观锁来说,如果出现冲突,就认为能够获取到锁,往往不会阻塞等待而是通过类似于忙等的方式

9.2 读写锁

把读操作和写操作分离开了。在代码中,有些情况时写的次数少,读的次数多就很适合读写锁

public class Demo29 {private static ReentrantReadWriteLock.ReadLock readLock = new ReentrantReadWriteLock().readLock();private static ReentrantReadWriteLock.WriteLock writeLock = new ReentrantReadWriteLock().writeLock();public static void main(String[] args) {// 加锁readLock.lock();writeLock.lock();// 释放锁readLock.unlock();writeLock.unlock();}
}

synchronized不是读写锁

如果使用普通的互斥锁

读操作 和 写操作 之间都进行了互斥行为

读和写操作之间都是串行

写和写之间也是串行

读和读之间还是串行【其实这个情况下是可以并行的】

读操作:加读锁

写操作:加写锁

写锁和写锁之间:互斥

读锁和写锁之间:互斥

读锁和读锁之间:不互斥

9.3 轻量级锁VS重量级锁

轻量级锁:

  1. 加锁机制尽可能不使用 mutex锁,而是尽量在用户态完成任务,实在搞不定在使用 mutex锁

  2. 少量内核态切换到用户态去执行任务,不太容易引发线程调度

  3. 一旦发生了锁竞争,此时的开销是比较小的(往往是用户态代码来完成。基于自旋锁实现)

重量级锁:

  1. 加锁机制严重依赖系统的 mutex锁
  2. 大量的内核态切换到用户态,很容易引发线程调度
  3. 一旦生了锁竞争,此时开销比较大的(往往是需要操作系统内部(内核态)完成一些工作的。基于通过挂起等待锁实现)

synchronized开始是轻量级锁,如果锁冲突比较严重就变成了重量级锁

和悲观锁没乐观锁类似。

悲观锁:重量

乐观锁:轻量

锁的核心在于 原子性 ,这样的机制要追溯到 CPU 这样的硬件设备上

  • CPU 提供了“原子操作指令”
  • 操作系统基于CPU的 “原子指令操作” 实现了 mutex锁
  • JVM 基于操作系统提供的互斥锁,实现了 synchronized,ReentrantLock 等关键字和类

9.4 自旋锁VS挂起等待锁

自旋锁:可以认为是轻量级锁的一种典型实现(自旋锁也可以视为是乐观锁的一种典型实现)

自旋锁伪代码

while(抢锁(locker) == 失败)

synchronized开始是自旋锁,后续竞争激烈就是挂起等待锁

如果获取锁失败,则while循环转得很快,出现 忙等,在这个循环过程中就会反复尝试获取锁,这种操作不涉及线程的阻塞和调度,非常耗费CPU资源,但是有个好处:当锁被其它线程释放的时候,就能第一时间获取这把锁

挂起等待锁:挂起等待锁也可以视为一种 重量级锁的一种典型实现,也可以视为悲观锁的一种典型实现

一旦获取锁失败,就挂起等待,与 自旋锁不放弃CPU 相反的是 放弃CPU,进入到内核中的阻塞队列。等待CPU的调度。涉及到内核态的操作,一旦锁被释放,也不能第一时间知道,但是好处是不消耗CPU资源

9.5 公平锁VS非公平锁

公平锁:如果多个线程发生了锁竞争,当锁被释放的时候,这些等待的线程,遵守先来后到的规则来拿锁

非公平锁:如果多个线程发生了锁竞争,当锁被释放的时候,这些线程不遵守先来后到的规则(这些线程谁都有机会拿到锁)

synchronized是非公平锁

操作系统内部对于线程调度本身就是无序/随机的,因此就可以视为系统自带的锁,就是非公平锁

如果要想实现公平锁,就需要使用额外的数据结构来进行限制(队列)

9.6 可重入锁VS不可重入锁

可重入锁:如果一个线程连续两次加锁,不会把自己给整 死锁 ,就是可重入锁【会记录当前锁是被那个线程持有,以及加锁的次数的计算】

不可重入锁:如果一个线程连续两次加锁,把自己给整 死锁 ,就是不可重入锁

synchronized是可重入锁

9.7 小结

synchronized锁策略

  1. 自适应乐观锁,悲观锁
  2. 不是读写锁
  3. 自适应自旋锁,挂起等待锁
  4. 自适应轻量级锁,重量级锁
  5. synchronized 是一个非公平锁
  6. synchronized 是一个可重入锁

9.8 相关面试题

  1. 你是怎样理解乐观锁,悲观锁的。具体怎样实现的呢?

    乐观锁:认为当前共享资源被线程访问的时候小概率引发激烈冲突,并不会真正的加锁而是直接尝试获取数据,同时判断当前访问的数据是否出现冲突

    乐观锁实现:引入一个版本号,借助版本号来判定当前数据是否出现冲突

    悲观锁:认为当前共享资源被线程访问的时候大概率会引发激烈冲突,所以每次访问都会真正的枷锁

    悲观锁实现:在获取共享资源之前就被加锁(借助操作系统),获取到锁之后才会访问数据,否则就会阻塞等待

  2. 介绍下读写锁

    读写锁就是把读操作,写操作进行加锁。

    读锁和读锁不互斥

    读锁和写锁互斥

    写锁和写锁互斥

    读写锁主要用在 频繁读,不频繁写 的场景中

  3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

    自旋锁:如果获取锁失败,则会立即尝试再次获取锁,无限循环直到获取锁为止。第一次获取失败,第二次会在极短的时间内到来。因此一旦其它线程释放锁资源,该线程就会立即得到锁。

    优点:没有释放CPU资源,一旦锁得到释放,就会第一时间获取到锁。主要用在 锁持有时间短 的场景中

    缺点:如果锁的持有时间较长,会消耗大量CPU资源

  4. synchronized是可重入锁吗?

    是可重入锁。

    可重入锁指的是连续多次加锁不会造成 死锁

    实现的方式就是在持有该锁的线程中记录身份,以及一个计数器(记录加锁次数)。如果 锁中记录的身份和线程身份是一致的,则 计数器加一 来代表又上了一次锁。

10. CAS

10.1 什么是CAS

CAS:Compare And Swap,字面意思就是 比较交换

CAS一个单个的CPU指令,因此CAS操作就是原子的。CAS这个机制就给实现线程安全版本的代码提供了一个新的思路,之前是通过加锁,把多个指令打包成整体,来实现线程安全。

现在就可以考虑直接基于CAS来实现一些修改操作,也能保证线程安全(不需要加锁)

一个CAS涉及到如下操作:

假设原始数据V,旧的值是A,需要修改的新值是B

  1. 比较A,与V是否相等(比较)
  2. 如果比较相等,B写入V(交换)
  3. 返回成功操作

CAS的伪代码

boolean CAS(address, expectValue, swapValue) {if (&address == expectedValue) {&address = swapValue;return true;}return false;
}

在伪代码中,可以看到这个操作的效果就好像是多个指令构成的。但是实际上,这个相当于是 CPU 提供了一条指令,就能直接完成这些工作。这里其实更严格的来说,是把CPU 寄存器中的值 和 内存中的值赋给了指定内存中

10.2 CAS是怎么实现的

有点类似于指令集,硬件层面给予支持,软件层面才能做到

10.3 CAS有哪些应用

  1. 实现原子类

    多线程里,并发的对同一个变量进行 ++,–等操作,操作都是线程不安全的。使用CAS就能封装出一组特殊的类,这样类也表示一个整数,同时真谛这样的整数进行++,–之类的操作,都是原子的线程安全的。

    package thread;import java.util.concurrent.atomic.AtomicInteger;public class Demo23 {private static AtomicInteger n = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {AtomicInteger num = new AtomicInteger(0);// 这个就相当于 num++num.getAndIncrement();// --numnum.decrementAndGet();Thread t1 = new Thread(() -> {for (int i = 0; i < 100; i++) {n.getAndIncrement();}});t1.start();Thread t2 = new Thread(() -> {for (int i = 0; i < 100; i++) {n.getAndIncrement();}});t2.start();t1.join();t2.join();System.out.println(n.get());}
    }
    // 运行结果
    200
    

    发现 AutomicXXX 创建出来的变量在多线程中++,–操作是线程安全的

    看一下getAndIncrement(IncrementAndGet)的伪代码

    class AtomicInteger {private int value;public int getAndIncrement() {int oldValue = value;while ( CAS(value, oldValue, oldValue+1) != true) {oldValue = value;}return oldValue;}
    }
    

    解析

  2. 实现自旋锁

    public class SpinLock {private Thread owner = null;public void lock() {/*通过 CAS 看当前锁是否被某个线程持有.如果这个锁已经被别的线程持有, 那么就自旋等待.如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.*/while (!CAS(this.owner, null, Thread.currentThread())) {}}public void unlock() {this.owner = null;}
    }
    

    解析

10.4 ABA问题如何处理

CAS的关键要点就是在比较的时候的确定当前值和旧值是否一样,才会进行交换操作(赋值操作)。但是现在区分不了这个内容的值是真的没改变过还是变了之后又变回来。

如何理解没有改变?

当买回来一个新手机

这个手机如果没有被厂家翻新过,就是新手机

这个手机中途掉了漆,坏了零件,被厂家维修过后再翻新,手机各个方面都和新手机一模一样。这就是翻新机

值的变化:A->B->A

而CAS是无法区别购买的手机是 新手机 还是 翻新机,这就有可能引起BUG

大部分情况对于t1线程这样反复横跳,t2线程对于是否修改了x是没有影响的,但不排除一些特殊情况

举个银行的例子:

假设李华银行有10块钱存款,李华想去取5块钱。取款机创建了两线程来并发执行-5的操作

按理说,一个线程执行-5成功,另外一个线程执行-5失败。

  1. 正常的流程

    a)存款10块钱,线程1读取数据为10,期待更新数据为5;线程2页读到数据为10,期待更新数据为5

    b)线程1执行成功,存款被改为5。线程2阻塞等待中…

    c)线程2发现数据为oldValue为5,而自己的value为10,CAS操作失败,无法修改

  2. 异常的流程

    a)存款10块钱,线程1读取数据为10,期待更新数据为5;线程2页读到数据为10,期待更新数据为5

    b)线程1执行成功,存款被改为5。线程2阻塞等待中…

    c)在线程2执行之前,李华的老板给他转了5块钱,让他加班,李华余额变为了10

    d)线程2发现oldValue为10,而自己的value也是10,因此CAS操作成功,继续扣款5块钱

    发现异常的流程中,李华被扣了两次5块钱,努力工作白给,这都是 ABA问题的BUG

解决方案

给要修改的值引入版本号,在CAS比较当前值和旧值的同时也要比较版本号是否符合预期

  1. CAS读取旧值的同时也读取版本号

  2. 正修改的时候

    a)如果当前value版本号和oldValue版本号相同,则修改数据并把版本号+1

    b)如果当前value版本号和oldValue版本号不同,则修改失败

    对比上述李华取款的例子:

    假设李华银行有10块钱存款,李华想去取5块钱。取款机创建了两线程来并发执行-5的操作。

    按理说,一个线程执行-5成功,另外一个线程执行-5失败。为了解决 ABA 问题,我们引入了版本号,初始值为1

    • 存款10块钱,线程1读取数据为10,版本号为1,期待更新数据为5;线程2页读到数据为10,版本号为1,期待更新数据为5

    • 线程1执行成功,存款被改为5,版本号改为2。线程2阻塞等待中…

    • 在线程2执行之前,李华的老板给他转了5块钱,让他加班,李华余额变为了10,版本号为3

    • 轮到线程2执行了,线程2发现oldValue为10,而自己的value也是10,但是因为版本号一个为1,一个3,当前版本小于新读取的版本,因此CAS操作失败

10.5 相关面试题

  1. 自己对CAS的理解

    全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 "读取内存, 比

    较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑

  2. ABA问题如何解决

    给要修改的数据添加一个版本号,在CAS比较数据当前值与旧值的同时还需比对版本号是否符合预期。

    如果发现版本号一致:就可以把新值赋值给旧值,并让当前版本号自增

    如果发现版本号不一致:oldValue比value大,版本号比自己之前读的还要高,就认为操作失败

11. synchronized背后的原理

11.1 基本特点

只考虑JDK1.8

  1. 开始是乐观锁,后来锁竞争加剧就会演变为悲观锁
  2. 开始是轻量级锁,如果锁持有时间较长就变重量级锁
  3. 轻量级锁通过自旋锁的方式实现「CAS」,重量级锁通过挂起等待锁实现
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

11.2 加锁工作过程

锁升级【锁膨胀】的过程:

偏向锁

并不是真正的加了锁,只是先做了个简单的标记(非常轻量的)记录这个锁属于哪个线程。

后续线程来访问这个共享资源,判断后续线程身份是否和标记相等:

  • 相等:没有锁竞争,此时就不用真的加锁了,不用其它同步操作的开销(避免了加锁解锁)
  • 不相等:有锁竞争,取消之前偏向锁的状态进入轻量级锁「竞争激烈的话会进入重量级锁

轻量级锁「基于自旋锁实现」

随着其它线程的竞争,偏向锁的状态被解除,升级为轻量级锁「基于自旋锁实现」

此处的轻量级锁就是通过 CAS 来实现的:

判断旧线程,新线程是否相等在做更新

  • 相等:就加锁成功

  • 不相等:就持续自旋式等待,不放弃CPU,直到更新成功

自旋锁的自适应性:自旋操作是一直消耗CPU资源,因此此处的自旋不会一直持续进行而是达到一定的时间或者重复次数后就停止自旋

重量级锁「基于挂起等待锁」

如果竞争进一步剧烈,就膨胀为重量锁。此处的重量锁就是内核态调用 mutex锁

  1. 先进入内核态,执行加锁操作
  2. 判断当前锁是否被占用
    • 没被占用:加锁成功,切换回到用户态
    • 被占用:加锁失败,此时线程进入锁的等待队列「阻塞队列」,等待被操作系统唤醒…经历了许久的等待后,这个锁被释放了,操作系统这时才想起这个挂起的线程,时间片轮转到该线程,于是唤醒这个线程尝试重新获取锁

11.3 其它的优化操作

  1. 锁消除概念

    属于编译器优化的机制,编译器会智能的分析当前这个代码是否有必要加锁,如果编译器认为没有必要加锁,就直接把程序中的锁代码给去除掉

    StringBuffer stringBuffer = new StringBuffer();
    stringBuffer.append("1");
    stringBuffer.append("1");
    stringBuffer.append("1");
    

    在单个线程中,StirngBuffer的synchronized会被编译器优化掉

  2. 锁粗化概念

    锁的粒度:当前这个锁对应的代码范围

    锁覆盖的代码越多,锁越粗

    锁覆盖的代码越少,锁越少

锁的粒度越细,竞争越激烈(是为了能够快速释放锁,让其它线程来使用),但是频繁的加锁消锁效率又会变得抵消。编译器会自动优化成大粒度的锁

11.4 相关面试题

  1. 什么是偏向锁?

    偏向锁并不是真正的加锁,而是锁的对象头中简单标示一下(记录该锁所属的线程),后续的线程使用过程中拿这个偏向锁中的记录的锁的身份信息和后续线程的身份信息进行比较

    如果相同,则认为没有线程参与竞争,那么就不会真正的执行加锁这个动作

    如果不相同,则认为有竞争,取消偏向锁状态转而进入轻量级锁

  2. synchronized 实现原理是什么?

硬件+软件层面解释

硬件:CPU-----(提供原子性操作)----->操作系统-----(提供mutex锁)----->JVM-----(提供了synchronized,ReentrantLock)----->Java代码

软件:加锁过程:偏向锁-----(竞争出现)----->轻量级锁「基于自旋锁实现」-----(竞争加剧)----->重量级锁「基于挂起等待锁」

如果竞争减轻,则也相应的减轻退化

12. Callable

12.1 Callable的用法

创建线程可以通过 继承Thread类,实现Runnable接口

Runnable描述任务,不关注返回值

Runnable 的 run 方法返回类型是 void

Callable 描述的任务,要关注返回值,提供了一个 call 方法,带有返回值(范型参数),根据需要制定当前的返回值类型

例如:创建一个线程完成计算1+2+3+…+1000

先看一下不使用Callable接口

public class Demo24 {static class Result {public int sum = 0;public Object locker = new Object();}public static void main(String[] args) throws InterruptedException {Result result = new Result();Thread t = new Thread() {@Overridepublic void run() {int sum = 0;for (int i = 1; i <= 1000; i++) {sum += i;}synchronized (result.locker) {result.sum = sum;result.locker.notify();}}};t.start();synchronized (result.locker) {while (result.sum == 0) {result.locker.wait();}System.out.println(result.sum);}}
}

代码有点长,而且还要控制同步

看一下使用了Callable接口

package thread;import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class Demo25 {public static void main(String[] args) throws ExecutionException, InterruptedException {// 1.创建一个线程,通过这个线程计算 1+2+3+...+1000:定义了个带有返回值任务的CallableCallable<Integer> callable=new Callable<Integer>() {@Overridepublic Integer call() throws Exception {int sum=0;for (int i = 1; i <= 1000; i++) {sum += i;}return sum;}};// Thread 的构造方法没有直接传 Callable 的版本,需要另外一个FutureTask类辅助一下FutureTask<Integer> task=new FutureTask<>(callable);Thread t=new Thread(task);t.start();/*FutureTask 是凭据,需要拿着这个凭据来取票如果当前这里的 callable 任务已经执行完了,get 就会立即获取到结果并返回如果当前这里的 callable 任务没有执行完,get 就会阻塞等待。当这里的 get 执行完毕之后,才能够得到这里的结果*/Integer result=task.get();System.out.println(result);}
}

12.2 相关面试题

  1. 介绍下 Callable 是什么

    Callable 是一个 interface。相当于把线程封装了一个返回值,方便多线程计算

    Callable描述的是带有返回值的任务;Runnable描述的是不带返回值的任务

    Callable 通常需要搭配 FutureTask 来使用,用来保存 Callable 运行的结果「Callable是在另外一个线程中运行的,什么时候结束也并不清楚,FutureStak 就负责等待任务结果出来的这个工作」

13. java.util.concurrent包下的常见类

13.1 ReentrantLock

标准库中提供了另外一个类,这个类也是实现了一个可重入锁。

通过 lock 方法来加锁,unlock 来解锁。

一定要在 finally 中进行 unlock原因是:万一加锁操作抛出了异常,就可能导致解锁的操作无法执行【此时的锁就无人能解----->死锁】

如果是 synchornized 就完全不用担心执行不到 unlock 方法, synchronized 解锁是和代码块绑定的

**问题来了:既然生了 synchronized 为何还生 ReentrantLock 呢? **

ReentrantLock 提供了更丰富的功能,能做到 synchronized 做不到的事情

ReentrantLock 和 synchronized区别:

  1. ReentrantLock 提供了一个公平锁的实现版本(构造方法中,通过一个标志位,制定是公平锁,还是非公平锁)

    ReentrantLock reentrantLock = new ReentrantLock(true);
    

    这个公平与非公平是在于更加强大的唤醒机制而言

    synchronized 是搭配 Object类 的 wait/notify 实现等待-唤醒,每次唤醒的是一个随机线程

    synchronized 是搭配Condition类实现 等待-唤醒,可以更精确控制唤醒哪一个线程

  2. ReentrantLock 提供了一个 tryLock 操作

    能够制定一个加锁的等待时间,synchronized 如果拿不到锁,就死等

    tryLock 能指定一个最大的等待时间

  3. ReentrantLock 是通过 Java 标准库的一个类,在 JVM 外部实现;sycnrhonized 大概率基于 C++ 实现

如何选择使用哪个锁呢?

  • 锁竞争不激烈的时候,使用 synchronized,效率更高,释放更方便
  • 锁竞争激烈的时候,使用 ReentrantLock 搭配 tryLock 更加灵活控制锁的行为,而不是死等
  • 如果需要使用 公平锁,使用 ReentrantLock

13.2 原子类介绍

还记得上文中的 CAS 吗?原子类内部就是用的 CAS,它的性能要比锁实现++,–这样的自增操作更优秀。原子类有以下几个:

  1. AtomicInteger
  2. AtomicLong
  3. AtomicBoolean
  4. AtomicIntegerArray
  5. AtomicReference
  6. AtomicStampedReference

以 AtomicInteger 为例,使用方法如下:

addAndGet(int num) i+=num
decrementAndGet() --i
getAndDecrement() i--
incrementAndGet() ++i
getAndIncrement() i++

13.3 线程池

线程池是为了解决线程频繁的创建销毁带来的性能下降问题。

线程在不使用了,并不是真正的把这个线程释放,而是放到一个 池子 中,下次如果需要用到这个线程就不必通过系统重新创建,直接去取就可以了

ExecutorService 和 Executors

ExecutorService 是一个线程池实例

Executors 是一个线程工厂类,能够创建出几种不同风格的线程池

ExecutorService 的 submit 能够像线程池中提交若干任务

ExecutorService pool= Executors.newFixedThreadPool(4);
pool.submit(new Runnable() {@Overridepublic void run() {System.out.println("hello thread");}
});

Executors 创建线程有以下几种方式:

  • newFixedThreadPool(int nThreads):创建固定线程数的线程池
  • newCachedThreadPool():创建一个线程数动态增长的线程池
  • newSingleThreadExecutor():创建一个只包含单个线程的线程池
  • newScheduledThreadPool(int corePoolSize):设定延迟时间后执行,或定期执行命令。是进阶版的 Timer

Executors 是 ThreadPoolExecutor 的封装

ThreadPoolExecutor 具有更多可选参数,进一步细化线程池的具体行为

理解 ThreadPoolExecutor 构造函数

  • 把创建线程想象成开一家创业公司

  • corePoolSize:正式员工,一旦录用,永不辞退

  • maximumPoolSize:正式员工+临时工的数量(临时工:一段时间不干活就被辞退)

  • keepAliveTime:临时工允许空闲的时间(摸鱼)

  • unit:keepAliveTime 的时间单位是小时,分钟还是秒。

  • workQUeue:传递任务的阻塞队列

  • threadFactory:创建线程的工厂,参与具体的创建线程工作

  • RejectedExecutionHandler:拒绝策略。如果公司业务量太多,负载太大。再接到新任务时候该怎么办

  • AbortPolicy():超过负荷, 直接抛出异常
  • CallerRunsPolicy():调用者负责处理
  • DiscardOldestPolicy():丢弃队列中最老的任务
  • DiscardPolicy():丢弃新来的任务.

代码实例

package thread;import java.util.concurrent.*;public class Demo26 {public static void main(String[] args) {ThreadPoolExecutor pool = new ThreadPoolExecutor(4, 100, 1000, TimeUnit.MILLISECONDS, new PriorityBlockingQueue<>(), new ThreadPoolExecutor.AbortPolicy());pool.submit(new Runnable() {@Overridepublic void run() {//TODO}});}
}

线程池的工作流程

13.3 信号量Semaphore

信号量是一个用来表示 可用资源数 的计时器。

理解信号量

可以理解信号量就是停车场的展示牌:当前有100个车位,表示有100个可用资源

当车辆驶入的时候,就相当于申请资源,可用车位就要减1(相当于P操作)

当车辆驶出的时候,就相当于释放资源,可用车位就要加1(相当于V操作)

如果计数器显示为 0 了,还在尝试申请资源就会阻塞等待,知道有其它线程资源释放

Semaphore的 PV操作 都是原子的,所以可以在多线程环境下使用

package thread;import java.util.concurrent.Semaphore;public class Demo27 {public static void main(String[] args) {// 构造方法的参数就是在制定可用资源一共有几个(最多可以连续 P操作 几次)Semaphore semaphore = new Semaphore(4);Runnable runnable = new Runnable() {@Overridepublic void run() {try {System.out.println("申请资源");semaphore.acquire();// acquire相当于 P操作(申请资源)System.out.println("我获取到资源了");Thread.sleep(1000);System.out.println("我释放资源了");semaphore.release();// release相当于 V操作(释放资源)} catch (InterruptedException e) {e.printStackTrace();}}};for (int i = 0; i < 20; i++) {Thread t = new Thread(runnable);t.start();}}
}
// 运行结果
我获取到资源了
申请资源
我获取到资源了
申请资源
我获取到资源了
申请资源
我获取到资源了
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
申请资源
我释放资源了
我获取到资源了
我释放资源了
我释放资源了
我释放资源了
我获取到资源了
我获取到资源了
我获取到资源了
我释放资源了
我获取到资源了
我释放资源了
我释放资源了
我获取到资源了
我释放资源了
我获取到资源了
我获取到资源了
我释放资源了
我获取到资源了
我释放资源了
我释放资源了
我获取到资源了
我获取到资源了
我释放资源了
我获取到资源了
我释放资源了
我获取到资源了
我释放资源了
我释放资源了
我获取到资源了
我释放资源了
我获取到资源了
我获取到资源了
我释放资源了
我释放资源了
我释放资源了
我释放资源了进程已结束,退出代码0

13.4 CountDownLatch

同时等待 N 个任务结束

就好比跑步比赛,4个选手一起准备就位,等待哨声响起才起跑,全部到达终点先之后才会公布成绩

初始化的时候,先去指定有多少名选手(线程)

每个线程到达终点(完成任务)就调用 CountDownLatch 里面的 countDown 方法(撞线)

撞线之前,使用 CountDownLatch 里的 await 方法来等待所有的线程到达终点(阻塞)

当所有的线程都到达了(调用 countDown 次数和最开始指定的初始次数相同)此时就让 await 返回(说明比赛结束)

package thread;import java.util.concurrent.CountDownLatch;public class Demo28 {public static void main(String[] args) throws InterruptedException {// 指定 10 个选手参赛来参赛CountDownLatch countDownLatch = new CountDownLatch(10);for (int i = 0; i < 10; i++) {Thread t = new Thread(() -> {try {Thread.sleep(2);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("选手撞线了!" + Thread.currentThread().getName());countDownLatch.countDown();});t.start();}// 等待所有的选手撞线countDownLatch.await();System.out.println("比赛结束");}
}
// 运行结果
选手撞线了!Thread-2
选手撞线了!Thread-6
选手撞线了!Thread-4
选手撞线了!Thread-5
选手撞线了!Thread-0
选手撞线了!Thread-3
选手撞线了!Thread-1
选手撞线了!Thread-9
选手撞线了!Thread-8
选手撞线了!Thread-7
比赛结束

应用场景也是非常实用的。

某个代码中任务量很大的时候,就拆分成很多个小任务,每个任务就都是用一个线程来完成(把这若干个任务都交给一个线程池来完成)

举例:

分出了 100 个任务,在每个任务完成之后,都调用 countDown() 方法,当任务调用 countDown() 计数够了之后,await就返回了

13.5 相关面试题

  1. 线程同步的方式有哪些

    1. synchronized
    2. ReentrantLock
    3. Semaphore
    4. CountDownLatch
  2. 为什么有了 synchronized 还需要 JUC下 的 lock?

    以 ReentrantLock 为例

    1. synchronized 使用结束后不需要手动释放锁;ReentrantLock 需要手动释放锁,使用起来更灵活
    2. synchronized 申请锁失败的时候是 死等;ReentrantLock 会通过 tryLock 等待一段时间就放弃
    3. synchronized 是非公平锁,通过 Object的 wait/notify 随机唤醒;ReentrantLock默认是非公平锁,通过构造方法传入true开启公平锁,通过 Condition类实现精准控唤醒某个指定的线程
  3. AtomicInteger 实现原理是什么

    基于 CAS机制 来实现的

    public class AtomicInteger{private int value;public int getAndIncrement(){int oldValue=value;while(!(CAS(oldValue, value, oldValue+1))){}return oldValue;}
    }
    
  4. 信号量听说过吗?之前在哪些场景下用到

    信号量表示可用资源个数,本质上是一个 “计数器”

    使用信号量可以实现 “共享锁”,比如某个共享资源允许4个线程同时访问,那么就可以使用P操作「acquire」加锁,V操作「release」解锁。前四个P操作顺利返回,后续的P操作会阻塞,等待V操作执行才会解锁,后续的阻塞才会慢慢进行P操作

  5. 解释一下 ThreadPoolExecutor 参数的含义

    使用

    ThreadPoolExecutor pool = new ThreadPoolExecutor(4, 100, 1000, TimeUnit.MILLISECONDS, new PriorityBlockingQueue<>(), new ThreadPoolExecutor.AbortPolicy());
    

    源码

    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler)
    

    把创建线程池理解为开公司,核心线程数就是正式员工

    • corePoolSize:正式员工数量「正式员工一经创建,永不辞退」
    • maximumPoolSize:最大员工数量「正式工+临时工」
    • keepAliveTime:允许的空闲时间「超出这个时间,临时工就会被辞退」
    • unit:空间时间的单位,如允许空闲1小时,1分钟,1秒钟
    • workQueue:可以手动指定一个任务队列,线程池里面也是要有一个队列来组织一大堆任务的【每个任务都用一个 Runnable 来表示的】
    • threadFactory:通过啥样的方式创建出线程池
      • newFixedThreadPool:创建一个固定线程数的线程池
      • newCachedThreadPool:创建出一个线程数动态增长的线程池
      • newSingletonThreadPool:创建出只包含一个单例的线程池
      • newScheduleThreadPool:创建了一个类似于定时器的线程池,也是延时执行一个任务
    • handler:当公司任务量太大,接到新任务的决策
      • AbortPolic:超出公司运转负荷,抛出异常
      • CallerRunsPolicy:交给用户决策
      • DiscardOldestPolicy:删除掉安排在后边的任务
      • DiscardPolicy:拒绝新任务

14. 线程安全的集合类

Vector,Stack,HashTable 是线程安全的(不建议使用,上古时期的集合类,实现方式就是简单粗暴的添加synchronized),其它都是线程不安全的

14.1 多线程环境下使用ArrayList

  1. 我们手动添加 synchronized 或者 ReentrantLack

  2. List<Integer> list = Collections.synchronizedList(new ArrayList<>());
    

    synchronizedList 是标准库中基于 synchronized 进行线程同步的 List

    synchronizedList 的关键操作上都带有 synchronized

  3. CopyOnWriteArrayList<Integer> list1=new CopyOnWriteArrayList<>();
    

    CopyOnWrite就是一个写时复刻的容器【修改的时候拷贝】

    • 当我们往容器中添加数据的时候,不直接进行添加,而是先把容器拷贝一份,复制出一个新的容器,然后往新的容器里添加元素
    • 添加操作完成后,在 空闲的时候 把原有的容器引用指向新的拷贝出来的容器

    这样做的好处就是我们可以实现对容器进行并发的读,因为当前容器并不会添加新的元素。所以 CopyOnWrite 也是一种读写分离的思想,读和写不同的容器

    优点:

    读多写少的情况下,性能很高不需要加锁

    缺点:

    1. 占用内存多
    2. 新添加的数据不能第一时间读到

14.2 多线程环境下使用队列

  1. BlockingQueue<Integer> queue=new ArrayBlockingQueue<>(4);
    

    基于数组实现的阻塞队列

  2. BlockingQueue<Integer> queue1=new LinkedBlockingQueue<>();
    

    基于链表实现的阻塞队列

  3. BlockingQueue<Integer> queue2=new PriorityBlockingQueue<>();
    

    基于堆实现的带有优先级的阻塞队列

  4. BlockingQueue<Integer> queue3=new TransferQueue<Integer>() {// 重写 TransferQueue 接口中的方法
    }
    

    最多只包含一个元素的阻塞队列

14.3 多线程环境下使用哈希表

多线程环境下 HashMap 本身是不安全的,但可以使用 HashTable,ConcurrentHashMap

哈希的线程安全用的居多,在面试中问的比较多,一定要学会

14.3.1 HashTable

看了看 HashTable 的 get 和 put 方法,我们发现是是一个 套壳 的线程安全。仅仅是在方法外边加了一个 sychronized关键字

这就相当于对 HashTable对象本身加锁

  • 当多个线程同时访问同一个 HashTable 就会造成锁竞争
  • 不仅仅是 put,get 被加了锁,还有 size 也加锁了,也是比较慢的「相当于给 this 加锁」
  • 还有一个严重问题就是当 一旦触发扩容,就由该线程完成整个扩容过程,涉及到大量元素的拷贝,效率会非常低

14.3.2 ConcurrentHashMap

相比于 HashTable 做出了一些改进和优化,以JDK1.8为例

  • 锁粒度更细

  • 读操作没有加锁(但是使用了 volatile ,保证内存可见性),只对写操作进行加锁,加锁的方式仍然是 synchronized,但是不是锁住整个对象,而是 “锁桶”(用每个链表的头节点作为锁对象),大大降低了锁冲突的概率

  • 充分利用了 CAS 特性,比如 size 通过 CAS 来更新,避免了重量级锁的出现

  • 优化了扩容方式:化整为零

    • 发现需要扩容的线程,只需要创建一个新的数组,同时搬运几个元素过去
    • 扩容期间,新老数组并存
    • 后序每个来操作 ConcurrentHashMap 的线程都会参与搬运过程,每个操作负责搬运一小部分元素
    • 搬完最后一个元素后把老数组给删除掉
    • 这个期间,数据的插入只往新数组中插入
    • 这个期间,数据的查询,需要同时查找新老数组

    就像蚂蚁搬家一样,每次只搬一点点,把一个大任务拆分成小任务来完成。完成全部的搬家后,旧的家就会被抛弃。

ConcurrentHashMap:把链表的头元素作为锁,只有当两个线程同时放同一个链表的头节点的时候才会发生锁竞争

14.4 相关面试题

  1. ConcurrentHashMap 读的时候是否需要加锁?为什么

    读操作没有加锁,目的是为了进一步提高效率,为了保证内存可见性使用 volatile 修饰

  2. 介绍下 ConcurrentHashMap 的分段技术

    分段锁JDK1.8之前ConcurrentHashMap 所采取的方式,但是从 JDK1.8开始ConcurrentHashMap 已经不在使用分段锁了,而是直接锁链表头

    分段锁的意思:把若干个这样的链表头归为一组,称为是一个分段,针对每一个分段分配一把锁,目的也是为了降低锁重提的概率,只是不如现在这种直接锁链表头这样彻底。

  3. ConcurrentHashMap 在JDBK1.8 都做了哪些优化

    1. 取消了分段锁,锁住链表头,锁细化的更彻底,减少锁竞争
    2. 将原来 数组+链表 的方式改为 数组+链表/红黑树 当链表长度过长的时候(大于等于8个元素)就转为红黑树
  4. HashMap,HashTable,ConcurrentHashMap 都有什么区别

    1. HashMap:线程不安全

    2. HashTable:线程安全

      采用 synchronized 锁住整个 HashTable 对象,效率低下,key不能为null

    3. ConcurrentHashMap:线程安全

      采用 synchronized 锁住链表头,降低锁竞争,充分利用CAS机制

      优化扩容方式,不带时之前 HashTable 单个线程拷贝整个数据,而是交给每个 put 操作的线程,每次搬运一点点数据,所有线程最终搬运完所有所有的数据

      key不允许为null

15 死锁

15.1 死锁是什么

死锁是这样一种情形: 多个线程同时被阻塞,它们中的一个或者多个在等待某个资源的释放,由于线程被无限期阻塞,因此程序就会卡丝,无法运行下去

举个例子理解死锁:

在和对象出去玩的时候,走在大街上。

女票说:我们去看 Mac口红 吧,色号很适合漂亮~

男票说:要不我们先去看新的 Mac笔记本吧,苹果新研发的芯片生产力更强~

女票说:不行,男人就该让着女人,先看了口红再去看电脑!

男票说:谁说的,这不公平,先看电脑在看口红!

如果双方之间互不相让,就构成了死锁

Mac口红 和 Mac笔记本 相当于两把锁,男票和女票是两个被锁住的线程

教科书上也有一个经典的案例:哲学家吃就餐问题

五个哲学家围坐在一张桌子上,桌上放着一碗意大利面条,每个哲学件之间放着一个筷子

哲学家只干两件事:思考人生或者吃面条:思考人生时候就会放下筷子,吃面条的时候就会拿起筷子(先拿左边再拿右边)

  • 如果哲学家需要吃面条的时候发现筷子被别的哲学家拿走了(筷子被别人占用),就会阻塞等待

  • 如果碰巧,同一时刻5个哲学家一起拿走了左手边的筷子,就会发现右手的筷子都被占用了,哲学家们互不相让,这个时候就形成了 死锁

死锁是一种很严重的BUG,导致一个线程无法 “卡死” 无法运行

15.2 如何避免死锁

产生死锁的四个必要条件

  1. 互斥使用「当一个线程资源被占用的时候,其它线程不能使用」
  2. 不可抢占「资源请求着不能强制从资源占有者手中夺取资源,资源只能由资源占用着释放」
  3. 请求和保持「资源请求着在请求其他资源的同时并不会失去对原有资源的占用」
  4. 循环等待「存在一个等待队列,P1占有P2,P2占有P3资源,P3占有P1资源。这样就形成了一个闭环」

这四个条件全部成立的话就会是一个 死锁,想要打破 死锁,就可以通过破坏掉其中一个环境即可。

其中最容破坏的就是 循环等待

破坏循环等待

最常用的一种阻止死锁技术就是 锁排序。N个线程M把锁,对M把锁进行按编号排序「1,2,3…M」。N个线程获取锁的时候就会按照锁的排序顺序来获取锁,这样就可以避免闭环等待

容易产生闭环的代码

public static void main(String[] args) {Object locker1=new Object();Object locker2=new Object();Thread t1=new Thread(()->{synchronized (locker1){//TODOsynchronized (locker2){//TODO}}});t1.start();Thread t2=new Thread(()->{synchronized (locker2){//TODOsynchronized (locker1){//TODO}}});t2.start();
}

不会产生闭环的代码

public static void main(String[] args) {Object locker1=new Object();Object locker2=new Object();Thread t1=new Thread(()->{synchronized (locker1){//TODOsynchronized (locker2){//TODO}}});t1.start();Thread t2=new Thread(()->{synchronized (locker1){//TODOsynchronized (locker2){//TODO}}});t2.start();
}

两者本质就是加锁的顺序比一样,按照顺序对来获取锁,就可以解决死锁问题

15.3 相关面试题

  1. 谈谈 volatile关键字的用法

    volatile 关键之强制CPU读取内存来防止编译器的优化读操作,当该变量被修改的时候,其它线程能够第一时间读取到变化后的值而不是变化前的值

  2. Java多线程是如何实现数据共享的

    JVM把内存分为:栈区,堆区,方法区,程序计数器

    其中堆区是多个线程之间共享的数据区「只要把某个数据放到堆中,就可以实现多个线程都访问到」

  3. Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么

    接口:

    1. Executors 工厂类创建,封装了 ThreadPoolExectuor,使用简单方便
    2. ThreadPoolExectuor:参数较多,但是定制能力强

    LinkedBlockingQueue:表示线程池的队列任务,用户通过 submit/execute 向队列中提交任务,由 工作线程 来执行任务

  4. Java线程共有几种状态?状态之间怎么切换的?

    共有 6

    • NEW:安排了工作,还未开始行动。新创建的线程,还没有调用 start 方法时处在这个状态
    • RUNNABLE: 可工作的。又可以分成正在工作中和即将开始工作。调用 start 方法之后,并正在 CPU 上运行/在即将准备运行的状态
    • BLOCKED:使用 synchronized 的时候,如果锁被其他线程占用,就会阻塞等待,从而进入该状 态
    • WAITING:调用 wait 方法会进入该状态
    • TIMED_WAITING:调用 sleep 方法或者 wait(超时时间) 会进入该状态
    • TERMINATED:工作完成了,当线程 run 方法执行完毕后,会处于这个状态
  5. 在多线程下,如果对一个数进行叠加,该怎么做?

    • 使用 synchronized/ReentrantLock 加锁
    • 使用 Callable 返回结果
    • 使用 AtomicInteger 原子类
  6. Servlet是否是线程安全的?

    Servlet 本身是在多线程环境下工作的。如果Servlet中有成员变量,则多个请求到达服务器的时候,服务器更具请求进行操作,是有可能找层造成数不安全的情况发生

  7. Thread和Runnable的区别和联系?

    • Thread 创建线程类
    • Runnable 描述任务

    当创建线程类的时候需要指定线程完成的任务,可重写 Thread 类的 run 方法;也可以通过 Runnable 来描述

  8. 多次start一个线程会怎么样?

    第一次调用 start 可以成功调用,后续调用会抛出异常 IllegalThreadStateException

  9. 有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?

    • synchronized 加在 非static 代码块上,相当于给这个 对象 加锁

    • 如果这两个方法属于同一个实例:

      线程1能够获取到锁,并执行方法,线程2会阻塞等待直到线程1执行完毕,锁被释放,线程2获取锁在运行线程2

    • 如果这两个方法属于同一个实例:

      两者能并发并行,且互不干扰

  10. 进程和线程的区别?

    • 线程包含在进程中。即:每个进程至少包含一个线程,也即是主线程
    • 同一个进程下的全部线程,资源共享;进程与进程之间不会共享
    • 进程是系统分配资源的最小单位;线程是系统调度的最小单位
  11. synchronized和lock获取不到锁分别进入什么状态?

    • synchronized:死等
    • lock:延时等待,超过后就放弃

当初我要是这么学习Java多线程就好了「附图文解析」相关推荐

  1. 当初我要是这么学习JVM就好了「附图文解析」

    文章目录 1. JVM 简介 2. JVM 运行流程 3. JVM 运行时数据区 3.1 程序计数器「线程私有」 3.2 Java虚拟机栈「线程私有」 3.3 本地方法栈「线程私有」 3.4 堆「线程 ...

  2. 当初我要是这么学习二叉树就好了「附图文解析」

    目录 1. 树形结构 1.1 概念(了解) 1.2 概念(重要) 1.3 树的表示形式 1.4 树的应用 2. 二叉树(BinaryTree重点) 2.1 概念 2.2 二叉树的5种基本形态 2.3 ...

  3. Java外卖点餐系统「附全部代码」

    前言 傻瓜式外卖点餐系统(无数据库) tips: 菜品类(菜品id,菜品名,菜品类型,上架时间,单价,月销售,总数量) 管理员类(管理员id,账号,密码) 客户类(客户id,客户名,性别,密码,送餐地 ...

  4. 学习java多线程,这必须搞懂的这几个概念

    转载自 学习java多线程,这必须搞懂的这几个概念,很重要. 同步和异步 同步,Synchronous,即调用方法开始,一旦调用就必须等待方法执行完返回才能继续下面的操作. 举个例子,你去银行ATM取 ...

  5. java火箭应用_从火箭发场景来学习Java多线程并发闭锁对象

    原标题:从火箭发场景来学习Java多线程并发闭锁对象 从火箭发场景来学习Java多线程并发闭锁对象 倒计时器场景 在我们开发过程中,有时候会使用到倒计时计数器.最简单的是:int size = 5; ...

  6. java 闭锁_从火箭发场景来学习Java多线程并发闭锁对象

    从火箭发场景来学习Java多线程并发闭锁对象 倒计时器场景 在我们开发过程中,有时候会使用到倒计时计数器.最简单的是:int size = 5; 执行后,size-这种方式来实现.但是在多线程并发的情 ...

  7. 如何学习Java多线程

    最近一段时间,我对<Java并发编程实践>这本经典而又有些难懂的书籍,尝试用了一些简单有趣.通俗易懂的方式进行解读,现整理成GitBook(文末有链接),方便大家阅读. 为什么要解读这本书 ...

  8. 没有任何基础的可以学python吗-没有任何基础的人,该如何学习Python?「附具体步骤」...

    原标题:没有任何基础的人,该如何学习Python?「附具体步骤」 Python是一门简单易学的语言,可是对于完全没有任何基础的小白来说,入门也是不容易的. 今天,我们来看一下,对于这部分同学来说,具体 ...

  9. 怎么学python-没有任何基础的人,该如何学习Python?「附具体步骤」

    原标题:没有任何基础的人,该如何学习Python?「附具体步骤」 Python是一门简单易学的语言,可是对于完全没有任何基础的小白来说,入门也是不容易的. 今天,我们来看一下,对于这部分同学来说,具体 ...

最新文章

  1. linux shell which 和 whereis 区别
  2. mysql用户权限设置
  3. hibernate的懒加载(延迟加载)问题
  4. 如何linux中文改为英文,CentOS系统如何将中文语言改成英文
  5. sklearn常见命令和官方文档汇总
  6. DCMTK:函数dcmGenerateUniqueIdentifier的测试程序
  7. 方立勋_30天掌握JavaWeb_数据库表设计
  8. Java中this与super的区别
  9. 3复数与复变函数(三)
  10. datatable 参数详细说明
  11. SiteMesh配置下载使用(简单介绍)
  12. 环境土壤物理模型HYDRUS1D/2D/3D实践技术
  13. 快手上推广一个月要多少钱,快手短视频广告投放一年多少钱
  14. 富士智能e7说明书_富士智能停车系统配置
  15. 一文看懂一般性采购、战略采购与项目型采购的区别
  16. speedoffice(Excel)表格中输入身份证号码显示不全怎么解决?
  17. LVGL (9) Event 机制实现
  18. Bailian——4074积水量
  19. cv2.error: OpenCV(4.5.1) C:\Users\appveyor\AppData\Local\Temp\1\pip-req-buil windows下的解决方案
  20. 国内首家!网易易盾加固第一时间适配Android Q Beta

热门文章

  1. c#如何将double转换成int
  2. css之实现图片自适应
  3. IDEA无法打开标有问号的文件
  4. h5精准定位_手机端H5地理定位结合腾讯地图API实现精准定位!
  5. 问题 C: 战略威慑(树的直径)
  6. 高通及安卓及QNX常用缩写
  7. .NET CORE API访问401错误
  8. 开源社区运营一些思考
  9. 简单使用Ansible-galaxy
  10. [并行计算] 1. 并行计算简介