java并发编程笔记整理(juc)(全)

18 篇文章 0 订阅
4 篇文章 0 订阅

✅作者简介:我是18shou,一名即将秋招的java实习生

🔥系列专栏:牛客面经专栏

📃推荐一款八股、面经、模拟面试、刷题神器👉 超级无敌牛逼之牛客

耗时数小时从牛客整理的面经以及笔记
在这里插入图片描述


什么是juc

java.util.concurrent工具包的简称就是juc,jdk1.5之后出现的

线程和进程的概念

进程:指在运行中的程序,程序一旦运行就是进程,同时进程也是是线程的容器,是系统进行资源分配和调度的单元,是资源分配的最小单元。是一个动态的过程:有它自身的产生,存在和消亡的过程。–生命周期

线程(thread)是操作系统能够进行运算调度的最小单元。它被包含在进程之中,是进程中的实际运作单位,一条线程指的是进程中一个单一顺序的控制流,是一个程序内部的一条执行路径,一个进程中可以并发多个线程,每条线程可以执行不同的任务,线程作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器(pc)

线程的状态

操作系统中的线程状态

创建:线程从创建到被cpu执行之前的这个阶段。

就绪:指线程已具备各种执行条件,一旦获取cpu便可执行。

运行:表示线程正获得cpu在运行。

阻塞:指线程在执行中因某件事而受阻,处于暂停执行的状态,阻塞的线程

不会去竞争cpu。

终止:线程执行完毕,接下来会释放线程占用的资源。

线程的生命周期图如下(进程与线程生命周期一样):

img

java生命周期

  1. new(新建)
  2. runnable(准备就绪)
  3. blocked(阻塞)
  4. waiting(一直等待到线程被唤醒)
  5. timed_waiting(设置了等待时间,过了时间就自动唤醒)
  6. terminated(终结)

wait/sleep的区别

  1. sleep是Thread的静态方法,wait是Object的方法,任何对象实例都能调用
  2. sleep在睡眠的同时不会释放锁,它也不需要占用锁。wait会在等待的同时自动释放锁,但调用它的前提是当前线程占有锁(即代码在synchronized中)。
  3. 它们都可以被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两个指令, 此时可能就会发生指令重排的问题

    1594701748458

    关键在于

    • 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'}

🔥系列专栏:牛客面试专栏

耗时数小时从牛客网整理的面经以及笔记

  • 38
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 42
    评论
评论 42
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值