✅作者简介:我是18shou,一名即将秋招的java实习生
🔥系列专栏:牛客面经专栏
📃推荐一款八股、面经、模拟面试、刷题神器👉 超级无敌牛逼之牛客
耗时数小时从牛客整理的面经以及笔记
文章目录
- 什么是juc
- 线程常用方法
- 常见线程安全类
- Monitor(锁)
- 锁的升级与对比
- 异步模式之生产者/消费者
- park & unpark与 Object 的wait & notify相比
- unpark原理
- 线程转换
- ReentrantLock (重点)
- 什么是公平锁? 什么是非公平锁?
- 条件变量 (可避免虚假唤醒)
- 互斥/同步应用方面
- java内存模型JMM
- 模式之 Balking (了解)
- volatile原理(重重点)
- volatile是如何保证有序性
- volatile不能解决指令交错 (不能解决原子性):
- double-checked locking (双重检查锁) 问题 (`重点`)
- 单例模式线程安全问题
- cas + 重试 的原理
- 为什么CAS+重试(无锁)效率高
- CAS 的特点 (乐观锁和[悲观锁](https://so.csdn.net/so/search?q=悲观锁&spm=1001.2101.3001.7020)的特点)
- LongAdder原理 (原理之伪共享)
- Unsafe (`重点`)
- 不可变设计
- 保护性拷贝
- 享元设计模式
- 实现一个简易的连接池
什么是juc
java.util.concurrent工具包的简称就是juc,jdk1.5之后出现的
线程和进程的概念
进程:指在运行中的程序,程序一旦运行就是进程,同时进程也是是线程的容器,是系统进行资源分配和调度的单元,是资源分配的最小单元。是一个动态的过程:有它自身的产生,存在和消亡的过程。–生命周期
线程(thread)是操作系统能够进行运算调度的最小单元。它被包含在进程之中,是进程中的实际运作单位,一条线程指的是进程中一个单一顺序的控制流,是一个程序内部的一条执行路径,一个进程中可以并发多个线程,每条线程可以执行不同的任务,线程作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器(pc)
线程的状态
操作系统中的线程状态
创建:线程从创建到被cpu执行之前的这个阶段。
就绪:指线程已具备各种执行条件,一旦获取cpu便可执行。
运行:表示线程正获得cpu在运行。
阻塞:指线程在执行中因某件事而受阻,处于暂停执行的状态,阻塞的线程
不会去竞争cpu。
终止:线程执行完毕,接下来会释放线程占用的资源。
线程的生命周期图如下(进程与线程生命周期一样):
java生命周期
- new(新建)
- runnable(准备就绪)
- blocked(阻塞)
- waiting(一直等待到线程被唤醒)
- timed_waiting(设置了等待时间,过了时间就自动唤醒)
- terminated(终结)
wait/sleep的区别
- sleep是Thread的静态方法,wait是Object的方法,任何对象实例都能调用
- sleep在睡眠的同时不会释放锁,它也不需要占用锁。wait会在等待的同时自动释放锁,但调用它的前提是当前线程占有锁(即代码在synchronized中)。
- 它们都可以被interrupted方法打断
并发/并行的区别
并发:同一时刻轮询执行多个线程,宏观上是同时进行,微观上是不同时间进行
并行:同一时刻执行多个线程
管程
管程:Monitor 监视器,是一种同步机制,保证同一个时间,只有一个线程能被访问
用户线程/守护线程
用户线程:自定义线程
守护线程:后台自动执行的线程,比如垃圾回收,只要其他非守护线程运行结束了,即使守护线程的代码没有执行完,也会执行结束
主线程结束了,用户线程还在运行,jvm存活,没有用户线程了,都是守护线程,jvm结束。
synchronized是java中的关键字,是一种同步锁。它修饰的对象有以下几种:
-
修饰一个代码块,被修饰的代码块被称为同步语句块,其作用的范围是大括号括起来的代码,作用的对象是调用这个代码块的对象
-
修饰一个方法,被修饰的方法被称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
-
修饰一个类,其作用的范围是synchronized括起来的部分,作用的对象是调用这个类的对象;
-
修饰一个静态方法。作用的范围是整个静态方法,作用的对象是这个类的所有对象
同步和异步
- 同步:需要等待上面代码的运行结果才能运行
- 异步:不需要等待上面的代码运行结果就能运行(多线程即是异步)
线程上下文切换
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个
任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这
个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换
什么时候切换?:
- 线程的cpu时间片用完
- 线程自己调用了使线程阻塞的操作
- 有更高优先级的线程需要运行
- 垃圾回收
当线程发生上下文切换时,需要由操作系统保存当前线程的状态并恢复另一个线程的状态,java中对应的概念就是程序计数器,它的作用是记住下一条jvm指令的执行地址,是线程私有的
线程常用方法
yield();//释放当前cpu的执行权
join();//在线程a中调用线程b的join方法,线程a会陷入阻塞状态直到线程b执行完毕 stop();//强制线程生命期结束,不推荐使用 boolean isAlive();//判断线程是否还或者 sleep(long timemilltime);//让当前线程睡眠指定的milltime毫秒,在指定的milltime毫秒时间内,当前线程是阻塞状态
**以下三个方法必须在同步代码块或者同步方法中使用,并且调用者必须是同步代码块或同步方法中的同步监视器(同一把锁)**否则会出现IllegalMonitorStateException异常
属于Object类中的方法
wait():一旦执行此方法,当前线程进入阻塞状态,并释放同步监视器 notify():一旦执行此方法,就会唤醒被wait的一个线程,如果有多个线程被wait,则唤醒优先级高的 notifyAll():唤醒所有线程
方法名 | 功能 | 注意 |
---|---|---|
run() | 普通的方法调用 | 如果你不调用start方法直接run,相当于没开启线程 |
join() | 插入线程并等待插入的线程调用结束(同步) | 在t2线程中调用t1.join()就停止t2直到t1执行结束 |
join(long n) | 插入线程并等待插入的线程调用n秒(限时同步) | 在t2线程中调用t1.join(1)就停止t2直到t1执行1秒 |
interrupt() | 打断线程 (static方法) | 如果打断线程正在sleep,wait,join会导致被打断的线程抛出interuptedexception,并清除打断标记,如果打断的正在运行的线程,则会涉及打断标记,park的线程被打断也会设置打断标记 |
yield() | static方法 | |
sleep(long n) | 提示线程调度器让出当前线程的执行权 | static方法 |
interrupted() | 判断当前线程是否被打断 | 会清除打断标记 static方法 |
LockSupport.park() | 打断线程 不往下执行 | 如果打断线程被interrupt打断后(打断标记为true )再执行就失效了除非把打断标记再设为false; |
LockSupport.unpark(目标线程) | 唤醒目标线程 | 在unpark原理中有所讲述 (下方) |
public class InterruptTest {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination twoPhaseTermination = new TwoPhaseTermination();
twoPhaseTermination.start();
Thread.sleep(3500);
twoPhaseTermination.stop();
}
}
class TwoPhaseTermination{
Thread monitor;
public void start(){
monitor= new Thread(()->{
while(true){
Thread currentThread = Thread.currentThread();
if(currentThread.isInterrupted()){
System.out.println("料理后事");
break;
}
try {
Thread.sleep(1000);
System.out.println("执行监控");
} catch (InterruptedException e) {
e.printStackTrace();
currentThread.interrupt();//重新设置打断标记 下次循环时就会进入料理后事阶段
// 不设置的话程序会在java.lang.InterruptedException: sleep interrupted后继续执行监控(循环退不出),因为在执行打断标记时 线程正在sleep方法 所以会清除打断标记导致继续循环
}
}
});
monitor.start();
}
public void stop(){
monitor.interrupt();
}
}
运行结果:
执行监控
执行监控
执行监控
java.lang.InterruptedException: sleep interrupted
at java.base/java.lang.Thread.sleep(Native Method)
at com.atguigu.juc.TwoPhaseTermination.lambda$start$0(InterruptTest.java:24)
at java.base/java.lang.Thread.run(Thread.java:834)
料理后事
常见线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- juc包下的类
Monitor(锁)
Monitor被翻译为监视器或管程
每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象的Mark Word中就被设置指向Monitor对象的指针
Java对象头
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽
(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽
等于4字节,即32bit,如表2-2所示。
表2-2 Java对象头的长度
锁的升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在
Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状
态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏
向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高
获得锁和释放锁的效率,下文会详细分析。
偏向锁
HotSpot[1]的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同
一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并
获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出
同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否
存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需
要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则
使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
- 偏向锁的撤销 :
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,
持有偏向锁的线程才会释放锁。
轻量级锁
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并
将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用
CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失
败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成
功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁
重量级锁
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
- (1)重量级锁加锁
- (2)重量级锁解锁
自旋优化
重量级锁竞争的时候,还可以使用自旋来优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞
适合多核cpu
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的ThreadID
当撤销偏向锁阈值超过20次后,JVM会这样觉得, 我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
批量撤销
当撤销偏向锁的阈值超过40次后,jvm会这样觉得,自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
t1:全部偏向t1; t2:一半撤销t1的偏向锁成为轻量级锁,一半偏向t2(批量重偏向); t3:一半轻量级锁,一半撤销t2的偏向锁成为轻量级锁 总共撤销了20次t1的偏向锁,20次t2的偏向锁
锁消除
如果线程锁住的对象不会被共享,JIT(即时编译器)会消除这个锁
异步模式之生产者/消费者
package com.atguigu.juc;
import java.util.LinkedList;
public class ProductConsumerTest2 {
public static void main(String[] args) {
MessageQueue messageQueue = new MessageQueue(3);
for (int i = 0; i <= 3; i++) {
int id=i;
new Thread(()->{
while(true){
messageQueue.produce(new Message(id,id));
}
},"生产者").start();
}
new Thread(()->{
while(true){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
messageQueue.take();
}
},"消费者").start();
}
}
class MessageQueue{
private LinkedList<Message> list=new LinkedList<>();
private int capCity;
public MessageQueue(int capCity) {
this.capCity = capCity;
}
public void take(){
synchronized (list){//消费
if(list.isEmpty()){
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}else {
Message message = list.removeFirst();
System.out.println("消费者消费"+message.getId()+" "+message.getVal());
list.notify();//这里注意要用list调用notify 不然锁的对象不同
}
}
}
public void produce(Message message){//生产
synchronized (list){
if(list.size() == capCity){
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("生产者已生产满仓");
}else {
list.add(new Message(message.getId(),message.getVal()));
System.out.println("生产者生产"+message.getId()+" "+message.getVal());
list.notify();//这里注意要用list
}
}
}
}
class Message{
private int id;
private int val;
@Override
public String toString() {
return "Message{" +
"id=" + id +
", val=" + val +
'}';
}
public Message(int id, int val) {
this.id = id;
this.val = val;
}
public int getId() {
return id;
}
public int getVal() {
return val;
}
}
park & unpark与 Object 的wait & notify相比
- wait,notify,notifyAll必须偶尔u和Object Monitor 一起使用,park,unpark不必
- park & unpark是以线程为单位来阻塞和唤醒线程,而notify只能随机唤醒一个等待线程,notfyAlld唤醒所有等待线程,相比不那么精确
- park & unpark可以先unpark(先唤醒),wait,notify不行
unpark原理
unpark方法可以先调用,调用后设置count为1,count最多为1
这时调用park方法无需阻塞,直接运行,但是会将count设置为0
如果先调用park方法 count没有被设置为0 那么线程将会进入阻塞
类似信号量的pv操作
线程转换
-
调用start方法 new-》runnable
-
调用object.wait方法 runnable-》waiting
-
调用notify ,interrupt
- 竞争锁成功 waiting -》 runnable
- 失败 waiting -》blocked
-
在当前线程调用(其他线程.join) 当前线程runnable -》waiting
- 调用join的线程结束 或 调用interrupt 当前线程 waiting-》runnable
-
当前线程调用LockSupport.park会让当前线程 runnable -》waiting
-
LockSupport.unpark(目标线程)或调用了线程的interrupt,会让目标线程 waiting -》 runnable
TIMED_WAITING
当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
调用LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING–> RUNNABLE
9、RUNNABLE <–> BLOCKED
t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE –> BLOCKED, 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争 成功,从 BLOCKED –> RUNNABLE ,其它失败的线程仍然 BLOCKED
10、 RUNNABLE <–> TERMINATED
当前线程所有代码运行完毕,进入 TERMINATED
ReentrantLock (重点)
ReentrantLock 的特点 (synchronized不具备的)
- 支持锁重入
可重入锁是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此 有权利再次获取这把锁
- 可中断
lock.lockInterruptibly() : 可以被其他线程打断的中断锁,中断后直接结束线程,不会陷入阻塞
- 可以设置超时时间
lock.tryLock(时间) : 尝试获取锁对象, 如果超过了设置的时间, 还没有获取到锁, 此时就退出阻塞队列, 并释放掉
自己拥有的锁
- 可以设置为公平锁
(先到先得) 默认是非公平, true为公平 new ReentrantLock(true)
- 支持多个条件变量( 有多个waitset)
(可避免虚假唤醒) - lock.newCondition()创建条件变量对象; 通过条件变量对象调用 await/signal方法, 等待/唤醒
tryLock:获取不到锁立刻结束线程
tryLock(long time,TImeutils):获取不到锁time时间后结束线程
什么是公平锁? 什么是非公平锁?
公平锁 (new ReentrantLock(true))
公平锁, 可以把竞争的线程放在一个先进先出的阻塞队列上
只要持有锁的线程执行完了, 唤醒阻塞队列中的下一个线程获取锁即可; 此时先进入阻塞队列的线程先获取到锁
非公平锁 (synchronized, new ReentrantLock())
非公平锁, 当阻塞队列中已经有等待的线程A了, 此时后到的线程B, 先去尝试看能否获得到锁对象. 如果获取成功, 此时就不需要进入阻塞队列了. 这样以来后来的线程B就先活的到锁了
所以公平和非公平的区别 : 线程执行同步代码块时, 是否回去尝试获取锁, 如果会尝试获取锁, 那就是非公平的, 如果不会尝试获取锁, 直接进入阻塞队列, 再等待被唤醒, 那就是公平的
如果不进如队列呢? 线程一直尝试获取锁不就行了?
一直尝试获取锁, 在synchronized轻量级锁升级为重量级锁时, 做的一个优化, 叫做自旋锁, 一般很消耗资源, cpu一直空转, 最后获取锁也失败, 所以不推荐使用。在jdk6对于自旋锁有一个机制, 在重试获得锁指定次数就失败等等
条件变量 (可避免虚假唤醒)
- lock.newCondition()创建条件变量对象; 通过条件变量对象调用await/signal方法, 等待/唤醒
Synchronized 中也有条件变量,就是Monitor监视器中的 waitSet等待集合,当条件不满足时进入waitSet 等待
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是 支持多个条件变量。
这就好比synchronized 是那些不满足条件的线程都在一间休息室等通知; (此时会造成虚假唤醒), 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒; (可以避免虚假唤醒)
使用要点:
await 前需要 获得锁
await 执行后,会释放锁,进入 conditionObject (条件变量)中等待
await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
竞争 lock 锁成功后,从 await 后继续执行
signal 方法用来唤醒条件变量(等待室)汇总的某一个等待的线程
signalAll方法, 唤醒条件变量(休息室)中的所有线程
互斥/同步应用方面
- 互斥:使用synchronized或Lock达到共享资源互斥
- 同步:使用wait/notify或Lock的条件变量来达到线程间通信效果
java内存模型JMM
JMM主要体现在以下几个方面
- 原子性 - 保证指令不会受线程上下文切换的影响
- 可见性 - 保证指令不会受cpu缓存的影响**
(JIT对热点代码的缓存优化)
** - 有序性 - 保证指令不会受 cpu指令并行优化的影响
模式之 Balking (了解)
- 定义:
Balking (犹豫)模式
用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回
。有点类似于单例。
@Slf4j(topic = "guizy.Test1")
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Monitor monitor = new Monitor();
monitor.start();
monitor.start();
monitor.start();
Sleeper.sleep(3.5);
monitor.stop();
}
}
@Slf4j(topic = "guizy.Monitor")
class Monitor {
Thread monitor;
//设置标记,用于判断是否被终止了
private volatile boolean stop = false;
//设置标记,用于判断是否已经启动过了
private boolean starting = false;
/**
* 启动监控器线程
*/
public void start() {
//上锁,避免多线程运行时出现线程安全问题
synchronized (this) {
if (starting) {
//已被启动,直接返回
return;
}
//启动监视器,改变标记
starting = true;
}
//设置线控器线程,用于监控线程状态
monitor = new Thread(() -> {
//开始不停的监控
while (true) {
if(stop) {
log.debug("处理后续儿事");
break;
}
log.debug("监控器运行中...");
try {
//线程休眠
Thread.sleep(1000);
} catch (InterruptedException e) {
log.debug("被打断了...");
}
}
});
monitor.start();
}
/**
* 用于停止监控器线程
*/
public void stop() {
//打断线程
stop = true;
monitor.interrupt();
}
volatile原理(重重点)
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障。(保证写屏障之前的写操作, 都能同步到主存中)
- 对 volatile 变量的读指令前会加入读屏障。(保证读屏障之后的读操作, 都能读到主存的数据)
volatile是如何保证有序性
写屏障
会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后读屏障
会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
volatile不能解决指令交错 (不能解决原子性):
写屏障仅仅是保证之后的读能够读到最新的结果
,但不能保证其它线程的读, 跑到它前面去有序性的保证也只是保证了本线程内相关代码不被重排序
double-checked locking (双重检查锁) 问题 (重点
)
首先synchronized可以保证它的临界区的资源是 原子性、可见性、有序性的, 有序性的前提是,
在synchronized代码块中的共享变量, 不会在代码块外使用到, 否则有序性不能被保证, 只能使用volatile来保证有序性
下面代码的第二个双重检查单例, 就出现了这个问题(在synchronized外使用到了INSTANCE), 此时synchronized就不能防止指令重排, 确保不了指令的有序性.
// 最开始的单例模式是这样的
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
/*
多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时
判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁,
防止多线程操作共享资源,造成的安全问题
*/
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
/*
首先上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁,
严重影响性能,再次判断INSTANCE==null吗, 此时肯定不为null, 然后就返回刚才创建的INSTANCE;
这样导致了很多不必要的判断;
所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,
如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行
if(instance==null)的判断, 如果已经有单例对象, 此时就不会再去同步块中获取锁了. 提高效率
*/
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
//但是上面的if(INSTANCE == null)判断代码没有在同步代码块synchronized中,
// 不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致 指令重排
以上的实现特点是:
-
懒汉式单例
-
首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁 (也就是上面的第二个单例)
-
有隐含的: 但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外, 这样会导致synchronized
无法保证指令的有序性, 此时可能会导致指令重排问题
注意: 但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton; 3: ifnonnull 37 // 判断是否为空 // ldc是获得类对象 6: ldc #3 // class cn/itcast/n5/Singleton // 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份 8: dup // 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中 // 将类对象的引用地址存储了一份,是为了将来解锁用 9: astore_0 10: monitorenter 11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton; 14: ifnonnull 27 // 新建一个实例 17: new #3 // class cn/itcast/n5/Singleton // 复制了一个实例的引用 20: dup // 通过这个复制的引用调用它的构造方法 21: invokespecial #4 // Method "<init>":()V // 最开始的这个引用用来进行赋值操作 24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton; 27: aload_0 28: monitorexit 29: goto 37 32: astore_1 33: aload_0 34: monitorexit 35: aload_1 36: athrow 37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton; 40: areturn
其中
17 表示创建对象,将对象引用入栈 // new Singleton
20 表示复制一份对象引用 // 复制了引用地址, 解锁使用
21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
24 表示利用一个对象引用,赋值给 static INSTANCE
可能jvm 会优化为:先执行 24(赋值),再执行 21(构造方法)。如果两个线程 t1,t2 按如下时间序列执行:通过上面的字节码发现, 这一步INSTANCE = new Singleton();操作不是一个原子操作, 它分为21, 24两个指令, 此时可能就会发生指令重排的问题
关键在于
- 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值
- 这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例 对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排。
- 注意在 JDK 5 以上的版本的 volatile 才会真正有效
解决:加入volatile禁用指令重排:
package com.atguigu.juc;
public final class Singleton {
private static volatile Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance(){
if(instance == null){//instance在synchronized外面 所以并不能防止指令重排
synchronized (Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
单例模式线程安全问题
// 问题1:为什么加 final,防止子类继承后更改
// 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例,如果进行反序列化的时候会生成新的对象,这样跟单例模式生成的对象是不同的。要解决直接加上readResolve()方法就行了,如下所示
public final class Singleton implements Serializable {
// 问题3:为什么设置为私有? 放弃其它类中使用new生成新的实例,是否能防止反射创建新的实例?不能。
private Singleton() {}
// 问题4:这样初始化是否能保证单例对象创建时的线程安全?没有,这是类变量,是jvm在类加载阶段就进行了初始化,jvm保证了此操作的线程安全性
private static final Singleton INSTANCE = new Singleton();
// 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由。
//1.提供更好的封装性;2.提供范型的支持
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
问题1 : 加final为了防止有子类, 因为子类可以重写父类的方法
问题2 : 首先通过反序列化操作, 也是可以创建一个对象的, 破坏了单例, 可以使用readResolve方法并返回instance对象, 当反序列化的时候就会调用自己写的readResolve方法
问题3 : 私有化构造器, 防止外部通过构造器来创建对象; 但不能防止反射来创建对象
问题4 : 因为单例对象是static的, 静态成员变量的初始化操作是在类加载阶段完成, 由JVM保证其线程安全 (这其实是利用了ClassLoader的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。)
问题5 : 通过向外提供公共方法, 体现了更好的封装性, 可以在方法内实现懒加载的单例; 可以提供泛型等
补充 : 任何一个readObject方法,不管是显式的还是默认的,它都会返回一个新建的实例,这个新建的实例不同于该类初始化时创建的实例。
// 问题1:枚举单例是如何限制实例个数的:创建枚举类的时候就已经定义好了,每个枚举常量其实就是枚举类的一个静态成员变量
// 问题2:枚举单例在创建时是否有并发问题:没有,这是静态成员变量
// 问题3:枚举单例能否被反射破坏单例:不能
// 问题4:枚举单例能否被反序列化破坏单例:枚举类默认实现了序列化接口,枚举类已经考虑到此问题,无需担心破坏单例
// 问题5:枚举单例属于懒汉式还是饿汉式:饿汉式
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做:加构造方法就行了
enum Singleton {
INSTANCE;
}
问题1 : 枚举类中, 只有一个INSTANCE, 就确保了它是单例的
问题2 : 没有并发问题, 是线程安全的, 因为枚举单例底层是一个静态成员变量, 它是通过类加载器的加载而创建的, 确保了线程安全
问题3 : 反射无法破坏枚举单例, 主要通过反射, newInstance的时候, 会在该方法中作判断, 如果检查是枚举类型, 就会抛出异常。
if ((this.clazz.getModifiers() & 16384) != 0)
throw new IllegalArgumentException(“Cannot reflectively create enum objects”);
问题4 : 反序列化不能破坏, 枚举类默认也实习了序列号接口. 但枚举类考虑到了这个问题, 不会破坏单例. 通过反序列化得到的并不是同一个单例对象; 除此之外, 还可以写上readResolve方法,
问题 5 : 属于饿汉式, 静态成员变量, 通过类加载器的时候就加载了。
问题 6 : 加构造方法
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 分析这里的线程安全, 并说明有什么缺点:synchronized加载静态方法上,可以保证线程安全。缺点就是锁的范围过大.
public static synchronized Singleton getInstance() {
if( INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
上面是一个懒汉式的单例, 代码存在性能问题: 当单例对象已经创建好了, 多个线程访问getInstance()
方法, 仍然会获取锁, 同步操作, 性能很低, 此时出现重复判断
, 因此要使用双重检查
public final class Singleton {
private Singleton() { }
// 问题1:解释为什么要加 volatile ?为了防止重排序问题
private static volatile Singleton INSTANCE = null;
// 问题2:对比实现3, 说出这样做的意义:提高了效率
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗?这是为了第一次判断时的并发问题。
if (INSTANCE != null) { // t2
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
问题1 : 因为在synchronized外部使用到了共享变量INSTANCE, 所以synchronized无法保证instance的有序性, 又因为instance = new Singleton()不是一个原子操作, 可分为多个指令. 此时通过指令重排, 可能会造成INSTANCE还未初始化, 就赋值的现象, 所以要给共享变量INSTANCE加上volatile,禁止指令重排
问题2 : 增加了双重判断, 如果存在了单例对象, 别的线程再进来就无需加锁判断, 大大提高性能
问题3 : 防止多线程并发导致不安全的问题:防止单例对象被重复创建. 当t1,t2线程都调用getInstance()方法, 它们都判断单例对象为空, 还没有创建;
此时t1先获取到锁对象, 进入到synchronized中, 此时创建对象, 返回单例对象, 释放锁;
这时候t2获得了锁对象, 如果在代码块中没有if判断, 则线程2认为没有单例对象, 因为在代码块外判断的时候就没有, 所以t2就还是会创建单例对象. 此时就重复创建了
public final class Singleton {
private Singleton() { }
// 问题1:属于懒汉式还是饿汉式:懒汉式,这是一个静态内部类。类加载本身就是懒惰的,在没有调用getInstance方法时是没有执行LazyHolder内部类的类加载操作的。
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题2:在创建时是否有并发问题,这是线程安全的,类加载时,jvm保证类加载操作的线程安全
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
cas + 重试 的原理
使用原子操作来保证线程访问共享资源的安全性, cas+重试的机制来确保(乐观锁思想), 相对于悲观锁思想的synchronized,reentrantLock
来说, cas的方式效率会更好!
@Override
public void withdraw(Integer amount) {
// 核心代码
// 需要不断尝试,直到成功为止
while (true){
// 比如拿到了旧值 1000
int prev = balance.get();
// 在这个基础上 1000-10 = 990
int next = prev - amount;
/*
compareAndSet 保证操作共享变量安全性的操作:
① 线程A首先获取balance.get(),拿到当前的balance值prev
② 根据这个prev值 - amount值 = 修改后的值next
③ 调用compareAndSet方法, 首先会判断当初拿到的prev值,是否和现在的
balance值相同;
3.1、如果相同,表示其他线程没有修改balance的值, 此时就可以将next值
设置给balance属性
3.2、如果不相同,表示其他线程也修改了balance值, 此时就设置next值失败,
然后一直重试, 重新获取balance.get()的值,计算出next值,
并判断本次的prev和balnce的值是否相同...重复上面操作
*/
if (atomicInteger.compareAndSet(prev,next)){
break;
}
}
}
在上面代码中的AtomicInteger类,保存值的value属性使用了volatile 修饰。获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
volatile可以用来修饰 成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。
注意: volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原子性)
CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果
为什么CAS+重试(无锁)效率高
-
使用CAS+重试—无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
-
打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
-
但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
CAS 的特点 (乐观锁和悲观锁的特点)
-
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
-
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
-
CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一,但如果竞争激烈(写操作多),可以想到重试必然频繁发生,反而效率会受影响
LongAdder原理 (原理之伪共享)
- 缓存行伪共享得从
缓存
说起 - 缓存与内存的速度比较
因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中CPU 要保证数据的一致性 (缓存一致性),如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效
因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因 此缓存行可以存下 2 个的 Cell 对象。这样问题来了:
- Core-0 要修改 Cell[0]
- Core-1 要修改 Cell[1]
- 无论谁修改成功,都会导致对方 Core 的缓存行失效,
- 比如 Core-0 中 Cell[0]=6000, Cell[1]=8000 要累加 Cell[0]=6001, Cell[1]=8000 ,这时会让 Core-1 的缓存行失效
@sun.misc.Contended
用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的 padding(空白),从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效
累加主要调用以下方法
public void add(long x) {
Cell[] as; long b, v; int m; Cell a;
if ((as = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended = a.cas(v = a.value, v + x)))
longAccumulate(x, null, uncontended);
}
}
Unsafe (重点
)
- Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过
反射
获得 - 可以发现
AtomicInteger
以及其他的原子类, 底层都使用的是Unsafe
类
- 使用底层的
Unsafe
实现原子操作
public class Test {
public static void main(String[] args) throws Exception {
// 通过反射获得Unsafe对象
Class unsafeClass = Unsafe.class;
// 获得构造函数,Unsafe的构造函数为私有的
Constructor constructor = unsafeClass.getDeclaredConstructor();
// 设置为允许访问私有内容
constructor.setAccessible(true);
// 创建Unsafe对象
Unsafe unsafe = (Unsafe) constructor.newInstance();
// 创建Person对象
Person person = new Person();
// 获得其属性 name 的偏移量
long nameOffset = unsafe.objectFieldOffset(Person.class.getDeclaredField("name"));
long ageOffset = unsafe.objectFieldOffset(Person.class.getDeclaredField("age"));
// 通过unsafe的CAS操作改变值
unsafe.compareAndSwapObject(person, nameOffset, null, "guizy");
unsafe.compareAndSwapInt(person, ageOffset, 0, 22);
System.out.println(person);
}
}
class Person {
// 配合CAS操作,必须用volatile修饰
volatile String name;
volatile int age;
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
不可变设计
如果一个对象在不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改
-
类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
-
如果一个对象在
不能够修改其内部状态(属性)
,那么它就是线程安全
的,因为不存在并发修改
。
不可变设计的要素:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[]; // 在JDK9 使用了byte[] 数组
/** Cache the hash code for the string */
private int hash; // Default to 0
// ...
}
- 发现该类、类中所有属性都是
final
的,属性用 final 修饰保证了该属性是只读
的,不能修改,类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
。
保护性拷贝
- 使用字符串时,也有一些跟修改相关的方法啊,比如
substring、replace
等,那么下面就看一看这些方法是 如何实现的,就以 substring 为例:
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 上面是一些校验,下面才是真正的创建新的String对象
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
-
发现其方法最后是调用
String 的构造方法创建了一个新字符串
,再进入这个构造看看,是否对 final char[] value 做出了修改:结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制。 -
这种通过创建副本对象来避免共享的手段称之为
【保护性拷贝(defensive copy)】
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
// 上面是一些安全性的校验,下面是给String对象的value赋值,新创建了一个数组来保存String对象的值
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
享元设计模式
-
简介定义英文名称:Flyweight pattern,
重用数量有限的同一类对象
- 结构型模式
-
享元模式的体现
-
1、在JDK中
Boolean,Byte,Short,Integer,Long,Character
等包装类提供了valueOf
方法,例如 Long 的valueOf
会缓存-128~127
之间的 Long 对象,在这个范围之间会重用对象,大于这个范围,才会新建 Long 对象
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}
Byte, Short, Long 缓存的范围都是-128-127
Character 缓存的范围是 0-127
Boolean 缓存了 TRUE 和 FALSE
Integer的默认范围是 -128~127,最小值不能变,但最大值可以通过调整虚拟机参数 "-
Djava.lang.Integer.IntegerCache.high "来改变
- 2、String 串池
- 3、BigDecimal, BigInteger
实现一个简易的连接池
/**
* Description: 简易连接池
*
* @author guizy
* @date 2020/12/29 21:21
*/
public class Test2 {
public static void main(String[] args) {
/*使用连接池*/
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}
@Slf4j(topic = "guizy.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;
// 2. 连接对象数组
private Connection[] connections;
// 3. 连接状态数组: 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;
// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);//使用AtomicIntegerArray保证states的线程安全
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i + 1));
}
}
// 5. 借连接
public Connection borrow() {
while (true) {
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if (states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {//使用compareAndSet保证线程安全
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 如果没有空闲连接,当前线程进入等待, 如果不写这个synchronized,其他线程不会进行等待,
// 一直在上面while(true), 空转, 消耗cpu资源
synchronized (this) {
try {
log.debug("wait...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
synchronized (this) {
log.debug("free {}", conn);
this.notifyAll();
}
break;
}
}
}
}
class MockConnection implements Connection {
private String name;
public MockConnection(String name) {
this.name = name;
}
@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}
// Connection 实现方法略
}
22:01:07.000 guizy.Pool [Thread-2] - wait...
22:01:07.000 guizy.Pool [Thread-0] - borrow MockConnection{name='连接1'}
22:01:07.005 guizy.Pool [Thread-4] - wait...
22:01:07.000 guizy.Pool [Thread-1] - borrow MockConnection{name='连接2'}
22:01:07.006 guizy.Pool [Thread-3] - wait...
22:01:07.099 guizy.Pool [Thread-0] - free MockConnection{name='连接1'}
22:01:07.099 guizy.Pool [Thread-2] - wait...
22:01:07.099 guizy.Pool [Thread-3] - borrow MockConnection{name='连接1'}
22:01:07.099 guizy.Pool [Thread-4] - wait...
22:01:07.581 guizy.Pool [Thread-3] - free MockConnection{name='连接1'}
22:01:07.582 guizy.Pool [Thread-2] - borrow MockConnection{name='连接1'}
22:01:07.582 guizy.Pool [Thread-4] - wait...
22:01:07.617 guizy.Pool [Thread-1] - free MockConnection{name='连接2'}
22:01:07.618 guizy.Pool [Thread-4] - borrow MockConnection{name='连接2'}
22:01:07.955 guizy.Pool [Thread-4] - free MockConnection{name='连接2'}
22:01:08.552 guizy.Pool [Thread-2] - free MockConnection{name='连接1'}
on(String name) {
this.name = name;
}
@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}
// Connection 实现方法略
}
```java
22:01:07.000 guizy.Pool [Thread-2] - wait...
22:01:07.000 guizy.Pool [Thread-0] - borrow MockConnection{name='连接1'}
22:01:07.005 guizy.Pool [Thread-4] - wait...
22:01:07.000 guizy.Pool [Thread-1] - borrow MockConnection{name='连接2'}
22:01:07.006 guizy.Pool [Thread-3] - wait...
22:01:07.099 guizy.Pool [Thread-0] - free MockConnection{name='连接1'}
22:01:07.099 guizy.Pool [Thread-2] - wait...
22:01:07.099 guizy.Pool [Thread-3] - borrow MockConnection{name='连接1'}
22:01:07.099 guizy.Pool [Thread-4] - wait...
22:01:07.581 guizy.Pool [Thread-3] - free MockConnection{name='连接1'}
22:01:07.582 guizy.Pool [Thread-2] - borrow MockConnection{name='连接1'}
22:01:07.582 guizy.Pool [Thread-4] - wait...
22:01:07.617 guizy.Pool [Thread-1] - free MockConnection{name='连接2'}
22:01:07.618 guizy.Pool [Thread-4] - borrow MockConnection{name='连接2'}
22:01:07.955 guizy.Pool [Thread-4] - free MockConnection{name='连接2'}
22:01:08.552 guizy.Pool [Thread-2] - free MockConnection{name='连接1'}
🔥系列专栏:牛客面试专栏
耗时数小时从牛客网整理的面经以及笔记