Skip to content

JUC

线程基础

线程和进程的区别?

程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。

当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

image-20250522211123367

一个进程之内可以分为一到多个线程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行

Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows 中进程是不活动的,只是作为线程的容器

image-20250522211301009

二者对比

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

面试官:说一下线程和进程的区别?

候选人:

嗯,好~

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

并行和并发有什么区别?

单核CPU

单核CPU下线程实际还是串行执行的

操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。

总结为一句话就是: 微观串行,宏观并行

一般会将这种线程轮流使用CPU的做法称为并发(concurrent)

image-20250522212642410

多核CPU

每个核(core)都可以调度运行线程,这时候线程可以是并行的。

CPU时间片1时间片2时间片3时间片4
core1线程1线程1线程3线程3
core2线程2线程4线程2线程4

比如说,在时间片1时,线程1和线程2是并行的,在时间片2时,线程1和线程4是并行的,而在这整个执行的过程中,可以说线程1、2、3、4是并发的。

image-20250522212900491

并发(concurrent)是某个时间段轮流做多件事情

并行(parallel)是某一时刻做多件事情

image-20250522213107806

面试官:聊一下并行和并发有什么区别?

候选人:

是这样的~~

现在都是多核CPU,在多核CPU下

并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU

并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程

创建线程的四种方式

参考回答:

共有四种方式可以创建线程,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程

继承Thread类

java
public class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("MyThread...run...");
    }

    
    public static void main(String[] args) {

        // 创建MyThread对象
        MyThread t1 = new MyThread() ;
        MyThread t2 = new MyThread() ;

        // 调用start方法启动线程
        t1.start();
        t2.start();

    }
    
}

实现runnable接口

java
public class MyRunnable implements Runnable{

    @Override
    public void run() {
        System.out.println("MyRunnable...run...");
    }

    public static void main(String[] args) {

        // 创建MyRunnable对象
        MyRunnable mr = new MyRunnable() ;

        // 创建Thread对象
        Thread t1 = new Thread(mr) ;
        Thread t2 = new Thread(mr) ;

        // 调用start方法启动线程
        t1.start();
        t2.start();

    }

}

实现Callable接口

java
public class MyCallable implements Callable<String> {

    @Override
    public String call() throws Exception {
        System.out.println("MyCallable...call...");
        return "OK";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        // 创建MyCallable对象
        MyCallable mc = new MyCallable() ;

        // 创建F
        FutureTask<String> ft = new FutureTask<String>(mc) ;

        // 创建Thread对象
        Thread t1 = new Thread(ft) ;
        Thread t2 = new Thread(ft) ;

        // 调用start方法启动线程
        t1.start();

        // 调用ft的get方法获取执行结果
        String result = ft.get();

        // 输出
        System.out.println(result);

    }

}

线程池创建线程

java
public class MyExecutors implements Runnable{

    @Override
    public void run() {
        System.out.println("MyRunnable...run...");
    }

    public static void main(String[] args) {

        // 创建线程池对象
        ExecutorService threadPool = Executors.newFixedThreadPool(3);
        threadPool.submit(new MyExecutors()) ;

        // 关闭线程池
        threadPool.shutdown();

    }

}

面试官:如果在java中创建线程有哪些方式?

候选人:

在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。

runnable 和 callable 有什么区别

Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果

Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛

面试官:好的,刚才你说的runnable 和 callable 两个接口创建线程有什么不同呢?

候选人:

是这样的~

最主要的两个线程一个是有返回值,一个是没有返回值的。

Runnable 接口run方法无返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果

还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息

在实际开发中,如果需要拿到执行的结果,需要使用Callalbe接口创建线程,调用FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

线程的 run()和 start()有什么区别?

start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

run(): 封装了要被线程执行的代码,可以被调用多次。

面试官:在我们使用线程的过程中,有两个方法。线程的 run()和 start()有什么区别?

候选人:

start方法用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。run方法封装了要被线程执行的代码,可以被调用多次。

线程包括哪些状态,状态之间是如何变化的?

线程的状态可以参考JDK中的Thread类中的枚举State

java
public enum State {
        /**
         * 尚未启动的线程的线程状态
         */
        NEW,

        /**
         * 可运行线程的线程状态。处于可运行状态的线程正在 Java 虚拟机中执行,但它可能正在等待来自		 * 操作系统的其他资源,例如处理器。
         */
        RUNNABLE,

        /**
         * 线程阻塞等待监视器锁的线程状态。处于阻塞状态的线程正在等待监视器锁进入同步块/方法或在调          * 用Object.wait后重新进入同步块/方法。
         */
        BLOCKED,

        /**
         * 等待线程的线程状态。由于调用以下方法之一,线程处于等待状态:
		* Object.wait没有超时
         * 没有超时的Thread.join
         * LockSupport.park
         * 处于等待状态的线程正在等待另一个线程执行特定操作。
         * 例如,一个对对象调用Object.wait()的线程正在等待另一个线程对该对象调用Object.notify()			* 或Object.notifyAll() 。已调用Thread.join()的线程正在等待指定线程终止。
         */
        WAITING,

        /**
         * 具有指定等待时间的等待线程的线程状态。由于以指定的正等待时间调用以下方法之一,线程处于定          * 时等待状态:
		* Thread.sleep
		* Object.wait超时
		* Thread.join超时
		* LockSupport.parkNanos
		* LockSupport.parkUntil
         * </ul>
         */
        TIMED_WAITING,

        /**
         * 已终止线程的线程状态。线程已完成执行
         */
        TERMINATED;
    }

状态之间是如何变化的

分别是

  • 新建

    当一个线程对象被创建,但还未调用 start 方法时处于新建状态

    此时未与操作系统底层线程关联

  • 可运行

    调用了 start 方法,就会由新建进入可运行

    此时与底层线程关联,由操作系统调度执行

  • 终结

    线程内代码已经执行完毕,由可运行进入终结

    此时会取消与底层线程关联

  • 阻塞

    当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间

    当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态

  • 等待

    当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间

    当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态

  • 有时限等待

    当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间

    当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁

    如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁

    还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态

面试官:线程包括哪些状态,状态之间是如何变化的?

候选人:

在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。

关于线程的状态切换情况比较多。我分别介绍一下

当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。

如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态

如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态

还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态

新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?

在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

t2中调用t1.join(),在t3中调用t2.join()最终无论你先启动哪个线程都会按照``t1 -> t2 -> t3的顺序执行。t1.join()需要在t1.start()后才能生效,t2`也是,每个线程都是如此。

java
public class JoinTest {

    public static void main(String[] args) {

        // 创建线程对象
        Thread t1 = new Thread(() -> {
            System.out.println("t1");
        }) ;

        Thread t2 = new Thread(() -> {
            try {
                t1.join();                          // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t2");
        }) ;


        Thread t3 = new Thread(() -> {
            try {
                t2.join();                              // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t3");
        }) ;

        // 启动线程
        t1.start();
        t2.start();
        t3.start();

    }

}

面试官:好的,我现在举一个场景,你来分析一下怎么做,新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?

候选人:

嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])

可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

比如说:

使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成

notify()和 notifyAll()有什么区别?

notifyAll:唤醒所有wait的线程

notify:只随机唤醒一个 wait 线程

java
package com.itheima.basic;

public class WaitNotify {

    static boolean flag = false;
    static Object lock = new Object();

    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            synchronized (lock){
                while (!flag){
                    System.out.println(Thread.currentThread().getName()+"...wating...");
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName()+"...flag is true");
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lock){
                while (!flag){
                    System.out.println(Thread.currentThread().getName()+"...wating...");
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println(Thread.currentThread().getName()+"...flag is true");
            }
        });

        Thread t3 = new Thread(() -> {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + " hold lock");
                lock.notifyAll();
                flag = true;
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();
        t2.start();
        t3.start();

    }

}

在 java 中 wait 和 sleep 方法的不同?

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同

    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同

    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)

    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

代码示例:

java
public class WaitSleepCase {

    static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        sleeping();
    }

    private static void illegalWait() throws InterruptedException {
        LOCK.wait();
    }

    private static void waiting() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                try {
                    get("t").debug("waiting...");
                    LOCK.wait(5000L);
                } catch (InterruptedException e) {
                    get("t").debug("interrupted...");
                    e.printStackTrace();
                }
            }
        }, "t1");
        t1.start();

        Thread.sleep(100);
        synchronized (LOCK) {
            main.debug("other...");
        }

    }

    private static void sleeping() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK) {
                try {
                    get("t").debug("sleeping...");
                    Thread.sleep(5000L);
                } catch (InterruptedException e) {
                    get("t").debug("interrupted...");
                    e.printStackTrace();
                }
            }
        }, "t1");
        t1.start();

        Thread.sleep(100);
        synchronized (LOCK) {
            main.debug("other...");
        }
    }
}

面试官:嗯,好的,刚才你说的线程中的 wait 和 sleep方法有什么不同呢?

候选人:

它们两个的相同点是都可以让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。

不同点主要有三个方面:

第一:方法归属不同

sleep(long) 是 Thread 的静态方法。而 wait(),是 Object 的成员方法,每个对象都有

第二:线程醒来时机不同

线程执行 sleep(long) 会在等待相应毫秒后醒来,而 wait() 需要被 notify 唤醒,wait() 如果不唤醒就一直等下去

第三:锁特性不同

wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制

wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃 cpu,但你们还可以用)

而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(相当于我放弃 cpu,你们也用不了)

如何停止一个正在运行的线程?

有三种方式可以停止线程

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
  • 使用stop方法强行终止(不推荐,方法已作废)
  • 使用interrupt方法中断线程

代码参考如下:

使用退出标志,使线程正常退出

java
public class MyInterrupt1 extends Thread {

    volatile boolean flag = false ;     // 线程执行的退出标记

    @Override
    public void run() {
        while(!flag) {
            System.out.println("MyThread...run...");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {

        // 创建MyThread对象
        MyInterrupt1 t1 = new MyInterrupt1() ;
        t1.start();

        // 主线程休眠6秒
        Thread.sleep(6000);

        // 更改标记为true
        t1.flag = true ;

    }
}

使用stop方法强行终止

java
public class MyInterrupt2 extends Thread {

    volatile boolean flag = false ;     // 线程执行的退出标记

    @Override
    public void run() {
        while(!flag) {
            System.out.println("MyThread...run...");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {

        // 创建MyThread对象
        MyInterrupt2 t1 = new MyInterrupt2() ;
        t1.start();

        // 主线程休眠2秒
        Thread.sleep(6000);

        // 调用stop方法
        t1.stop();

    }
}

使用interrupt方法中断线程

java
package com.itheima.basic;

public class MyInterrupt3 {

    public static void main(String[] args) throws InterruptedException {

        //1.打断阻塞的线程
        /*Thread t1 = new Thread(()->{
            System.out.println("t1 正在运行...");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "t1");
        t1.start();
        Thread.sleep(500);
        t1.interrupt();
        System.out.println(t1.isInterrupted());*/


        //2.打断正常的线程
        Thread t2 = new Thread(()->{
            while(true) {
                Thread current = Thread.currentThread();
                boolean interrupted = current.isInterrupted();
                if(interrupted) {
                    System.out.println("打断状态:"+interrupted);
                    break;
                }
            }
        }, "t2");
        t2.start();
        Thread.sleep(500);
        t2.interrupt();

    }
}

面试官:那如何停止一个正在运行的线程呢?

候选人

有三种方式可以停止线程

第一:可以使用退出标志,使线程正常退出,也就是当run方法完成后线程终止,一般我们加一个标记

第二:可以使用线程的stop方法强行终止,不过一般不推荐,这个方法已作废

第三:可以使用线程的interrupt方法中断线程,内部其实也是使用中断标志来中断线程

我们项目中使用的话,建议使用第一种或第三种方式中断线程

线程安全

synchronized关键字的底层原理

基本使用

如下抢票的代码,如果不加锁,就会出现超卖或者一张票卖给多个人

Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住

java
public class TicketDemo {

    static Object lock = new Object();
    int ticketNum = 10;


    public synchronized void getTicket() {
        synchronized (this) {
            if (ticketNum <= 0) {
                return;
            }
            System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
            // 非原子性操作
            ticketNum--;
        }
    }

    public static void main(String[] args) {
        TicketDemo ticketDemo = new TicketDemo();
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                ticketDemo.getTicket();
            }).start();
        }
    }


}

Monitor重量级锁

重量级锁的实现依赖于Monitor,Monitor 被翻译为监视器,是由jvm提供,c++语言实现

在代码中想要体现monitor需要借助javap命令查看clsss的字节码,比如以下代码:

java
public class SyncTest {

    static final Object lock = new Object();
    static int counter = 0;
    public static void main(String[] args) {
        synchronized (lock) {
            counter++;
        }
    }
}

找到这个类的class文件,在class文件目录下执行javap -v SyncTest.class,反编译效果如下:

image-20230504165342501

  • monitorenter 上锁开始的地方
  • monitorexit 解锁的地方
  • 其中被monitorenter和monitorexit包围住的指令就是上锁的代码
  • 有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁

在使用了synchornized代码块时需要指定一个对象,所以synchornized也被称为对象锁

monitor需要跟这个对象产生关联(如何关联在后面锁升级部分会解释),如下图

image-20230504165833809

Monitor内部具体的存储结构:

  • Owner:存储当前获取锁的线程的,只能有一个线程可以获取

  • EntryList:关联没有抢到锁的线程,处于Blocked状态的线程

  • WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程

具体的流程:

  • 代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否有线程持有
  • 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
  • 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)
  • 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待

总结:

  • Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】

  • 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor

  • 在monitor内部有三个属性,分别是owner、entrylist、waitset

  • 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程

锁升级

在 JDK 1.6 以前,所有的锁都是”重量级“锁,因为使用的是操作系统级别的互斥锁,当一个线程持有锁时,其他试图进入synchronized块的线程将被阻塞,直到锁被释放。涉及到了线程上下文切换和用户态与内核态的切换,因此效率较低。

实际上,大多数情况下,并不存在多个线程竞争同一个锁对象的情况,即使是一个高并发的系统,它的高并发也只会在某个时间段出现,没有高并发,那多线程竞争同一个锁对象的概率就很低了,在几乎没有线程竞争的情况下,依然使用重量级锁,就会导致性能很低。

为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁” 的概念,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

在 JDK 1.6 及其以后,一个锁对象其实有四种锁状态,它们会随着锁竞争情况由低到高逐渐升级,级别由低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态
对象的内存结构

在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充,而锁对象各种锁状态下的关键信息被记录于对象头的MarkWord部分

image-20230504172253826

MarkWord

在不同的锁状态下,MarkWord的结构是不一样的:

image-20230504172541922

  • hashcode:25位的对象标识Hash码,如果Mark Word中存在对象的hashcode,则不会进入偏向锁状态。

  • age:对象分代年龄占4位

  • biased_lock:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁

  • thread:偏向锁状态下,持有偏向锁的线程ID,占23位

  • epoch:偏向锁状态下,偏向时间戳,占2位

  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占30位

  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针,占30位

我们可以通过lock的标识,来判断是哪一种锁的等级

  • 后三位是001表示无锁
  • 后三位是101表示偏向锁
  • 后两位是00表示轻量级锁
  • 后两位是10表示重量级锁
无锁

在 没有任何线程持有锁的情况下,锁对象处于无锁状态(Unlocked),此时任何线程都可以尝试获取这把锁,也就是尝试修改(CAS )它的状态。但能否成功获取锁,还取决于是否竞争成功。

偏向锁

当一个线程第一次进入同步块时,JVM 会通过 CAS 操作将该线程的 ID 记录在锁对象的对象头(Mark Word)中,表示该对象的锁偏向于该线程。此后,若同一线程再次进入该同步块,只需检查对象头中的线程 ID 是否为自己,若是,则说明锁已偏向当前线程,线程可以直接进入同步块,无需 CAS 操作,从而大幅提升加锁效率。

如果有其他线程尝试获取该偏向锁,JVM 会进行以下处理:

检查偏向线程是否存活:

  • 如果偏向线程不再活跃(如已退出或长时间未参与竞争),JVM 会尝试使用 CAS 操作将锁对象的对象头中的线程 ID 替换为当前线程的 ID,从而完成偏向锁的转移,锁仍然处于偏向锁状态;
  • 如果偏向线程仍然活跃,JVM 会发起偏向锁的撤销流程:

撤销偏向锁并升级:

  • 等待偏向线程到达安全点然后将其挂起;
  • 将锁对象对象头的 Mark Word 中的偏向标志设置为 0;
  • 将锁标志位设置为 00,表示升级为轻量级锁;
  • 后续锁的获取将通过轻量级锁的机制进行,即通过 CAS + 自旋 竞争来加锁。

偏向锁在没有竞争的情况下几乎没有加锁开销,一旦发生竞争会自动升级为轻量级锁,并在必要时进一步升级为重量级锁(monitor)。

轻量级锁

两个或以上的线程同时竞争同一把锁,但无激烈竞争或仅有短暂重叠,线程通过少量次数的自旋,最终也能成功获取锁,即几乎不存在锁竞争的情况。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。

加锁的流程

首先 HotSpot 会先读对象头,判断它是不是已经指向当前线程栈上那个首次 CAS 成功LockRecord。如果没有指向,那就是首次加锁,如果已经指向,那就是重入。

首次加锁时,在线程栈中创建一个Lock Record,将其内部的 obj 指针指向锁对象,并将对象头(Mark Word)中的旧值拷贝到Lock Record中。HotSpot 源码中将这块锁记录备份的 Mark Word旧值 称为:Displaced Mark Word

image-20230504173520412

通过 CAS 指令在锁对象对象头(mark word)新建指针指向 Lock Record 地址,如果锁对象处于无锁状态则可以修改成功,代表该线程获得了轻量级锁。

image-20230504173611219

重入时,直接跳过 CAS,在栈上再分配一个新的 LockRecorddisplaced header 置 0,仅作重入计数)后进入同步块。对象头指针不变,仍指向最初的记录。

image-20230504173922343

如果 CAS 修改失败,说明发生了锁竞争,需要膨胀为重量级锁。

在轻量级锁中,每次加锁都需要cas,不管是否重入,而偏向锁只需要在重入的情况下,只需要在第一次加锁时执行cas。

解锁过程

当执行 monitorexit 指令时,JVM 会按照如下步骤解锁:

JVM 不需要遍历线程栈,而是直接从当前方法的栈帧中定位对应的 Lock Record,因为每次 monitorenter 都在栈帧中预留了对应槽位。

然后通过 displaced_mark_word 是否为 null判断是否为重入记录:

  • null:说明是重入记录,当前记录仅作计数用;
  • 不为 null:说明是首次加锁的主记录。

image-20230504173955680

如果是重入记录的话,仅将 Lock Record 的 _obj 设置为 null,不执行 CAS,直接返回。

如果是主记录的话,尝试通过 CAS 操作将对象头恢复为无锁状态,也就是将之前对象头(MarkDow)的旧值还回去。

这个 CAS 操作会出现两种结果:

  • CAS 成功,解锁完成;
  • CAS 失败,说明锁状态已被其他线程修改(膨胀为重量级锁),进入下一步。

如果发现膨胀为重量级锁,会以重量级锁的方式释放锁。JVM 读取对象头中的 ObjectMonitor 指针,调用其 exit() 方法完成解锁。

image-20230504174045458

再谈Monitor重量级锁

当多个线程竞争同一把锁时,JVM 会先尝试使用轻量级锁的方式,也就是通过 CAS + 自旋 加锁。也就是让线程先尝试通过 CAS 加锁,如果 CAS 失败不会立刻膨胀为重量级锁,而是先让当前线程自旋一段时间,期望持锁线程很快释放锁,从而避免线程挂起带来的性能损耗。

这个自旋阈值是自适应的,JVM 会根据历史自旋成功与否的情况动态调整自旋阈值。如果线程一直自旋等待锁的释放,而迟迟未能获取锁,就会进入一种“忙等”状态,白白占用 CPU 资源,造成性能浪费,因此需要设置阈值。

如果自旋等待后仍无法获得锁,JVM 认为轻量级锁无法满足当前竞争需求,就会将锁升级为重量级锁,线程也会被挂起并加入 monitor 的阻塞队列,直到被唤醒。

前面Monitor重量级锁部分有一个遗留问题,Monitor和锁对象是如何关联的?

每个 Java 对象都可以关联一个 Monitor 对象,锁对象进入重量级锁状态后,该对象头的Mark Word 中就会被设置指向 Monitor 对象的指针,让对象与monitor产生关联。

image-20230504172957271

总结

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

描述
重量级锁底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
偏向锁一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

一旦锁发生了竞争,都会升级为重量级锁

面试官:讲一下synchronized关键字的底层原理?

候选人

嗯~~好的,

synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。

面试官:好的,你能具体说下Monitor 吗?

候选人

monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

monitor内部维护了三个变量

  • WaitSet:保存处于Waiting状态的线程

  • EntryList:保存处于Blocked状态的线程

  • Owner:持有锁的线程

只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。

面试官:好的,那关于synchronized 的锁升级的情况了解吗?

候选人

嗯,知道一些(要谦虚)

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性

偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

一旦锁发生了竞争,都会升级为重量级锁

JMM(Java 内存模型)

JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型。

Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

image-20230504181638237

特点:

  • 所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
  • 线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。

CAS 你知道吗?

概述及基本工作流程

CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。

在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作

  • AbstractQueuedSynchronizer(AQS框架)

  • AtomicXXX类

例子:

我们还是基于刚才学习过的JMM内存模型进行说明

  • 线程1与线程2都从主内存中获取变量int a = 100,同时放到各个线程的工作内存中

image-20230504181947319

一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功

  • 线程1操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 101 (a++)
    • 线程1拿A的值与主内存V的值进行比较,判断是否相等
    • 如果相等,则把B的值101更新到主内存中

image-20230504182129820

  • 线程2操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 99(a--)
    • 线程2拿A的值与主内存V的值进行比较,判断是否相等(目前不相等,因为线程1已更新V的值99)
    • 不相等,则线程2更新失败

image-20230504181827330

  • 自旋锁操作

    • 因为没有加锁,所以线程不会陷入阻塞,效率较高

    • 如果竞争激烈,重试频繁发生,效率会受影响

image-20230504182447552

需要不断尝试获取共享内存V中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功

CAS 底层实现

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令

image-20230504182737931

都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现

image-20230504182838426

在java中比较常见使用有很多,比如ReentrantLock和Atomic开头的线程安全类,都调用了Unsafe中的方法

  • ReentrantLock中的一段CAS代码

image-20230504182958703

乐观锁和悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,一开始认为线程安全问题不会发生,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,一开始认为线程安全问题一定会发生,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

面试官:介绍一下CAS

候选人

好的。

CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。

  • CAS使用到的地方很多:AQS框架、AtomicXXX类

  • 在操作共享变量的时候使用的自旋锁,效率上更高一些

  • CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现

AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。

内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态

在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中

  • tail 指向队列最后一个元素

  • head 指向队列中最久的一个元素

其中我们刚刚聊的ReentrantLock底层的实现就是一个AQS。

谈谈你对 volatile 的理解

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

保证变量在线程间的可见性

当一个线程修改了一个被 volatile 修饰的变量,新值会立即对其他线程可见

当一个线程修改了一个被 volatile 修饰的变量时,这个新值会立即被刷新到主内存中。其他线程在读取该变量时,会强制从主内存中获取最新的值,而不会使用自己线程工作内存中的缓存副本,从而实现了变量的可见性。

一个典型的例子:永不停止的循环

java
public class VolatileDemo {
    private static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            System.out.println("子线程开始运行...");
            while (running) {
                // do nothing
            }
            System.out.println("子线程停止!");
        });

        thread.start();

        Thread.sleep(1000); // 等待1秒
        running = false;    // 修改 running 为 false,期望子线程退出
        System.out.println("主线程设置 running = false");
    }
}
  • 主线程将 running 设置为 false,但子线程可能永远不会看到这个修改,导致无限循环。
  • 因为 running 没有用 volatile 修饰,子线程可能一直读取工作内存中的旧值 true

使用 volatile 修饰后,解决可见性问题

java
public class VolatileDemo {
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            System.out.println("子线程开始运行...");
            while (running) {
                // do nothing
            }
            System.out.println("子线程停止!");
        });

        thread.start();

        Thread.sleep(1000); // 等待1秒
        running = false;    // 修改 running 为 false,子线程能立即看到
        System.out.println("主线程设置 running = false");
    }
}

禁止指令重排序

用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果

image-20230505082441116

@Actor,保证方法内的代码在同一个线程下指向

在去获取上面的结果的时候,有可能会出现4种情况

情况一:先执行actor2获取结果--->0,0(正常)

情况二:先执行actor1中的第一行代码,然后执行actor2获取结果--->0,1(正常)

情况三:先执行actor1中所有代码,然后执行actor2获取结果--->1,1(正常)

情况四:先执行actor1中第二行代码,然后执行actor2获取结果--->1,0(发生了指令重排序,影响结果)

指令重排序可能会导致代码程序出现不可预见的结果

解决方案

在变量上添加volatile,禁止指令重排序,则可以解决问题

image-20230505082835588

屏障添加的示意图

image-20230505082923729

  • 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
  • 读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上

其他补充

我们上面的解决方案是把volatile加在了int y这个变量上,我们能不能把它加在int x这个变量上呢?

下面代码使用volatile修饰了x变量

image-20230505083124159

屏障添加的示意图

image-20230505083217904

这样显然是不行的,主要是因为下面两个原则:

  • 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
  • 读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上

所以,现在我们就可以总结一个volatile使用的小妙招:

  • 写变量让volatile修饰的变量的在代码最后位置
  • 读变量让volatile修饰的变量的在代码最开始位置

面试官:请谈谈你对 volatile 的理解

候选人

嗯~~

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

什么是AQS?

难易程度:☆☆☆

出现频率:☆☆☆

概述

全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架,它是构建锁或者其他同步组件的基础框架

AQS与Synchronized的区别

synchronizedAQS
关键字,c++ 语言实现java 语言实现
悲观锁,自动释放锁悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差锁竞争激烈的情况下,提供了多种解决方案

AQS常见的实现类

  • ReentrantLock 阻塞式锁

  • Semaphore 信号量

  • CountDownLatch 倒计时锁

工作机制

  • 在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,0表示无锁,1表示有锁
  • 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet

image-20230505083840046

  • 线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
  • 线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待,
  • FIFO是一个双向队列,head属性表示头结点,tail表示尾结点

如果多个线程共同去抢这个资源是如何保证原子性的呢?

image-20230505084451193

在去修改state状态的时候,使用的cas自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待

AQS是公平锁吗,还是非公平锁?

  • 新的线程与队列中的线程共同来抢资源,是非公平锁

  • 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁

比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源

面试官:介绍一下AQS

候选人

好的。

AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。

内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态

在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中

  • tail 指向队列最后一个元素

  • head 指向队列中最久的一个元素

ReentrantLock底层的实现就是一个AQS。

ReentrantLock的实现原理

概述

ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:

  • 可中断

  • 可以设置超时时间

  • 可以设置公平锁

  • 支持多个条件变量

  • 与synchronized一样,都支持重入

image-20230505091736569

实现原理

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

查看ReentrantLock源码中的构造方法:

image-20230505091827720

提供了两个构造方法,不带参数的默认为非公平

如果使用带参数的构造函数,并且传的值为true,则是公平锁

其中NonfairSync和FairSync这两个类父类都是Sync

image-20230505092151244

而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的

image-20230505091833629

工作流程

image-20230505092340431

  • 线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功

  • 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部

  • 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程

  • 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁

面试官:synchronized它在高并发量的情况下,性能不高,在项目该如何控制使用锁呢?

候选人

嗯,其实,在高并发下,我们可以采用ReentrantLock来加锁。

面试官:嗯,那你说下ReentrantLock的使用方式和底层原理?

候选人

好的,

ReentrantLock是一个可重入锁:,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。

ReentrantLock是属于juc报下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。

它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。

synchronized和Lock有什么区别 ?

参考回答

  • 语法层面
    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
  • 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
  • 性能层面
    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈时,Lock 的实现通常会提供更好的性能

面试官:synchronized和Lock有什么区别 ?

候选人

嗯~~,好的,主要有三个方面不太一样

第一,语法层面

  • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
  • Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁

第二,功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
  • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock

第三,性能层面

  • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
  • 在竞争激烈时,Lock 的实现通常会提供更好的性能

统合来看,需要根据不同的场景来选择不同的锁的使用。

死锁产生的条件是什么?

死锁:一个线程需要同时获取多把锁,这时就容易发生死锁

例如:

  • t1 线程获得A对象锁,接下来想获取B对象的锁

  • t2 线程获得B对象锁,接下来想获取A对象的锁

代码如下:

java
package com.itheima.basic;

import static java.lang.Thread.sleep;

public class Deadlock {

    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                System.out.println("lock A");
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println("lock B");
                    System.out.println("操作...");
                }
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            synchronized (B) {
                System.out.println("lock B");
                try {
                    sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    System.out.println("lock A");
                    System.out.println("操作...");
                }
            }
        }, "t2");
        t1.start();
        t2.start();
    }
}

控制台输出结果

image-20220902171032898

此时程序并没有结束,这种现象就是死锁现象...线程t1持有A的锁等待获取B锁,线程t2持有B的锁等待获取A的锁。

面试官:死锁产生的条件是什么?

候选人

嗯,是这样的,一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:

t1 线程获得A对象锁,接下来想获取B对象的锁

t2 线程获得B对象锁,接下来想获取A对象的锁

这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁

如何进行死锁诊断?

当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack

步骤如下:

第一:查看运行的进程

image-20220902171426738

第二:使用jstack查看线程运行的情况,下图是截图的关键信息

运行命令:jstack -l 46032

image-20220902172229567

其他解决工具,可视化工具

  • jconsole

用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具

打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行

  • VisualVM:故障处理工具

能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈

打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行

面试官:如何进行死锁诊断?

候选人

这个也很容易,我们只需要通过jdk自动的工具就能搞定

我们可以先通过jps来查看当前java程序运行的进程id

然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的高效Map集合

底层数据结构:

  • JDK1.7底层采用分段的数组+链表实现

  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

JDK1.7中concurrentHashMap

数据结构

image-20230505092654811

  • 提供了一个segment数组,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容
  • 在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的
  • 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表

存储流程

image-20230505093055382

  • 先去计算key的hash值,然后确定segment数组下标
  • 再通过hash值确定hashEntry数组中的下标存储数据
  • 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁失败会使用cas自旋锁进行尝试

JDK1.8中concurrentHashMap

在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表

采用 CAS + Synchronized来保证并发安全进行实现

  • CAS控制数组节点的添加

  • synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升

image-20230505093507265

面试官:那你能聊一下ConcurrentHashMap的原理吗?

候选人

嗯好的,

ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。

  • JDK1.7的底层采用是分段的数组+链表 实现
  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构 的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修 改时,必须首先获得对应的 Segment的锁。

Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元 素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁

在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保 证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲 突,就不会产生并发 , 效率得到提升

导致并发程序出现问题的根本原因是什么

Java并发编程三大特性

  • 原子性

  • 可见性

  • 有序性

原子性

一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行

比如,如下代码能保证原子性吗?

image-20230505205200628

以上代码会出现超卖或者是一张票卖给同一个人,执行并不是原子性的

解决方案:

1.synchronized:同步加锁

2.JUC里面的lock:加锁

image-20230505210853493

内存可见性

内存可见性:让一个线程对共享变量的修改对另一个线程可见

比如,以下代码不能保证内存可见性

image-20230505211002252

解决方案:

  • synchronized

  • volatile(推荐)

  • LOCK

有序性

指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

还是之前的例子,如下代码:

image-20230505211209336

解决方案:

  • volatile

面试官:该如何保证Java程序在多线程的情况下执行安全呢?

候选人

嗯,刚才讲过了导致线程安全的原因,如果解决的话,jdk中也提供了很多的类帮助我们解决多线程安全的问题,比如:

  • JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
  • synchronized、volatile、LOCK,可以解决可见性问题
  • Happens-Before 规则可以解决有序性问题

线程池

说一下线程池的核心参数(线程池的执行原理)

线程池核心参数主要参考ThreadPoolExecutor这个类的7个参数的构造函数

image-20230505220514872

  • corePoolSize 核心线程数目

  • maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)

  • keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放

  • unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等

  • workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  • threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等

  • handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

工作流程

image-20230505220701835

1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行

2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列

3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务

如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务

4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略

拒绝策略:

  • AbortPolicy:直接抛出异常,默认策略;

  • CallerRunsPolicy:用调用者所在的线程来执行任务;

  • DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

  • DiscardPolicy:直接丢弃任务;

java
public class TestThreadPoolExecutor {

    static class MyTask implements Runnable {
        private final String name;
        private final long duration;

        public MyTask(String name) {
            this(name, 0);
        }

        public MyTask(String name, long duration) {
            this.name = name;
            this.duration = duration;
        }

        @Override
        public void run() {
            try {
                LoggerUtils.get("myThread").debug("running..." + this);
                Thread.sleep(duration);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        @Override
        public String toString() {
            return "MyTask(" + name + ")";
        }
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicInteger c = new AtomicInteger(1);
        ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(2);
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
                2,
                3,
                0,
                TimeUnit.MILLISECONDS,
                queue,
                r -> new Thread(r, "myThread" + c.getAndIncrement()),
                new ThreadPoolExecutor.AbortPolicy());
        showState(queue, threadPool);
        threadPool.submit(new MyTask("1", 3600000));
        showState(queue, threadPool);
        threadPool.submit(new MyTask("2", 3600000));
        showState(queue, threadPool);
        threadPool.submit(new MyTask("3"));
        showState(queue, threadPool);
        threadPool.submit(new MyTask("4"));
        showState(queue, threadPool);
        threadPool.submit(new MyTask("5",3600000));
        showState(queue, threadPool);
        threadPool.submit(new MyTask("6"));
        showState(queue, threadPool);
    }

    private static void showState(ArrayBlockingQueue<Runnable> queue, ThreadPoolExecutor threadPool) {
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        List<Object> tasks = new ArrayList<>();
        for (Runnable runnable : queue) {
            try {
                Field callable = FutureTask.class.getDeclaredField("callable");
                callable.setAccessible(true);
                Object adapter = callable.get(runnable);
                Class<?> clazz = Class.forName("java.util.concurrent.Executors$RunnableAdapter");
                Field task = clazz.getDeclaredField("task");
                task.setAccessible(true);
                Object o = task.get(adapter);
                tasks.add(o);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        LoggerUtils.main.debug("pool size: {}, queue: {}", threadPool.getPoolSize(), tasks);
    }

}

面试官:线程池的核心参数有哪些?

候选人

在线程池中一共有7个核心参数:

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数

  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目

  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放

  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等

  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等

  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

在拒绝策略中又有4中拒绝策略

当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。

面试官:线程池的执行原理知道吗?

候选人

嗯~,它是这样的

首先判断线程池里的核心线程是否都在执行任务,如果不是则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队 列里。如果工作队列满了,则判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任 务。如果已经满了,则交给拒绝策略来处理这个任务。

线程池中有哪些常见的阻塞队列

workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue

  • ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。

  • LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。

  • DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的

  • SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作,每个移出操作也都必须等待一个插入操作。

ArrayBlockingQueue的LinkedBlockingQueue区别

LinkedBlockingQueueArrayBlockingQueue
默认无界,支持有界强制有界
底层是链表底层是数组
是懒惰的,创建节点的时候添加数据提前初始化 Node 数组
入队会生成新 NodeNode需要是提前创建好的
两把锁(头尾)一把锁

左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式

  • LinkedBlockingQueue读和写各有一把锁,性能相对较好
  • ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些

image-20230505221424359

如何确定核心线程数

在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型

  • IO密集型任务

一般来说:文件读写、DB读写、网络请求等

推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)

  • CPU密集型任务

一般来说:计算型代码、Bitmap转换、Gson转换等

推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)

java代码查看CPU核数

image-20230505221837189

总结

① 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换

② 并发不高、任务执行时间长

  • IO密集型的任务 --> (CPU核数 * 2 + 1)

  • 计算密集型任务 --> ( CPU核数+1 )

③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)

面试官:如何确定核心线程数呢?

候选人

是这样的,我们公司当时有一些规范,为了减少线程上下文的切换,要根据当时部署的服务器的CPU核数来决定,我们规则是:CPU核数+1就是最终的核心线程数。

线程池的种类有哪些

在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种

创建使用固定线程数的线程池

image-20230505221959259

  • 核心线程数与最大线程数一样,没有救急线程

  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE

  • 适用场景:适用于任务量已知,相对耗时的任务

    java
    public class FixedThreadPoolCase {
    
        static class FixedThreadDemo implements Runnable{
            @Override
            public void run() {
                String name = Thread.currentThread().getName();
                for (int i = 0; i < 2; i++) {
                    System.out.println(name + ":" + i);
                }
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            //创建一个固定大小的线程池,核心线程数和最大线程数都是3
            ExecutorService executorService = Executors.newFixedThreadPool(3);
    
            for (int i = 0; i < 5; i++) {
                executorService.submit(new FixedThreadDemo());
                Thread.sleep(10);
            }
    
            executorService.shutdown();
        }
    
    }

单线程化的线程池

它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行

image-20230505222050294

  • 核心线程数和最大线程数都是1

  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE

  • 适用场景:适用于按照顺序执行的任务

    java
    public class NewSingleThreadCase {
    
        static int count = 0;
    
        static class Demo implements Runnable {
            @Override
            public void run() {
                count++;
                System.out.println(Thread.currentThread().getName() + ":" + count);
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            //单个线程池,核心线程数和最大线程数都是1
            ExecutorService exec = Executors.newSingleThreadExecutor();
    
            for (int i = 0; i < 10; i++) {
                exec.execute(new Demo());
                Thread.sleep(5);
            }
            exec.shutdown();
        }
    
    }

    可缓存线程池

image-20230505222126391

  • 核心线程数为0

  • 最大线程数是Integer.MAX_VALUE

  • 阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

  • 适用场景:适合任务数比较密集,但每个任务执行时间较短的情况

    java
    public class CachedThreadPoolCase {
    
        static class Demo implements Runnable {
            @Override
            public void run() {
                String name = Thread.currentThread().getName();
                try {
                    //修改睡眠时间,模拟线程执行需要花费的时间
                    Thread.sleep(100);
    
                    System.out.println(name + "执行完了");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            //创建一个缓存的线程,没有核心线程数,最大线程数为Integer.MAX_VALUE
            ExecutorService exec = Executors.newCachedThreadPool();
            for (int i = 0; i < 10; i++) {
                exec.execute(new Demo());
                Thread.sleep(1);
            }
            exec.shutdown();
        }
    
    }

    提供了“延迟”和“周期执行”功能的线程池

image-20230505222203615

  • 适用场景:有定时和延迟执行的任务

    java
    public class ScheduledThreadPoolCase {
    
        static class Task implements Runnable {
            @Override
            public void run() {
                try {
                    String name = Thread.currentThread().getName();
    
                    System.out.println(name + ", 开始:" + new Date());
                    Thread.sleep(1000);
                    System.out.println(name + ", 结束:" + new Date());
    
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            //按照周期执行的线程池,核心线程数为2,最大线程数为Integer.MAX_VALUE
            ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2);
            System.out.println("程序开始:" + new Date());
    
            /**
             * schedule 提交任务到线程池中
             * 第一个参数:提交的任务
             * 第二个参数:任务执行的延迟时间
             * 第三个参数:时间单位
             */
            scheduledThreadPool.schedule(new Task(), 0, TimeUnit.SECONDS);
            scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS);
            scheduledThreadPool.schedule(new Task(), 5, TimeUnit.SECONDS);
    
            Thread.sleep(5000);
    
            // 关闭线程池
            scheduledThreadPool.shutdown();
    
        }
    
    }

面试官:线程池的种类有哪些?

候选人

嗯!是这样

在jdk中默认提供了4中方式创建线程池

第一个是:newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回 收空闲线程,若无可回收,则新建线程。

第二个是:newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列 中等待。

第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

为什么不建议用Executors创建线程池

参考阿里开发手册《Java开发手册-嵩山版》

image-20220821003816845

面试官:为什么不建议使用Executors创建线程池呢?

候选人

好的,其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了

主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。

所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。

使用场景

线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)

CountDownLatch

CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)

  • 其中构造参数用来初始化等待计数值

  • await() 用来等待计数归零

  • countDown() 用来让计数减一

image-20230505223014946

案例代码:

java
public class CountDownLatchDemo {

    public static void main(String[] args) throws InterruptedException {
        //初始化了一个倒计时锁 参数为 3
        CountDownLatch latch = new CountDownLatch(3);

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"-begin...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            //count--
            latch.countDown();
            System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
        }).start();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"-begin...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            //count--
            latch.countDown();
            System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
        }).start();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"-begin...");
            try {
                Thread.sleep(1500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            //count--
            latch.countDown();
            System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
        }).start();
        String name = Thread.currentThread().getName();
        System.out.println(name + "-waiting...");
        //等待其他线程完成
        latch.await();
        System.out.println(name + "-wait end...");
    }
    
}

案例一(es数据批量导入)

在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出

整体流程就是通过CountDownLatch+线程池配合去执行

image-20230505223219951

详细实现流程:

image-20230505223246059

案例二(数据汇总)

在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?

image-20230505223442924

  • 在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能

  • 报表汇总

    image-20230505223536657

案例三(异步调用)

image-20230505223640038

在进行搜索的时候,需要保存用户的搜索记录,而搜索记录不能影响用户的正常搜索,我们通常会开启一个线程去执行历史记录的保存,在新开启的线程在执行的过程中,可以利用线程提交任务

面试官:你在项目中哪里用了多线程?

候选人

嗯~~,我想一下当时的场景[根据自己简历上的模块设计多线程场景]

参考场景一:

es数据批量导入

在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。

参考场景二:

在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行

参考场景三:

《黑马头条》项目中使用的

我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用

如何控制某个方法允许并发访问线程的数量?

Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,我们可以通过其限制执行的线程数量,达到限流的效果

当一个线程执行时先通过其方法进行获取许可操作,获取到许可的线程继续执行业务逻辑,当线程执行完成后进行释放许可操作,未获取达到许可的线程进行等待或者直接结束。

Semaphore两个重要的方法

lsemaphore.acquire(): 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)

lsemaphore.release():释放一个信号量,此时信号量个数+1

线程任务类:

java
public class SemaphoreCase {
    public static void main(String[] args) {
        // 1. 创建 semaphore 对象
        Semaphore semaphore = new Semaphore(3);
        // 2. 10个线程同时运行
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {

                try {
                    // 3. 获取许可
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                try {
                    System.out.println("running...");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("end...");
                } finally {
                    // 4. 释放许可
                    semaphore.release();
                }
            }).start();
        }
    }

}

面试官:如果控制某一个方法允许并发访问线程的数量?

候选人

嗯~~,我想一下

在jdk中提供了一个Semaphore[seməfɔːr]类(信号量)

它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了

第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1

其他

谈谈你对ThreadLocal的理解

概述

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享

案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。

image-20230505224057228

ThreadLocal基本使用

三个主要方法:

  • set(value) 设置值

  • get() 获取值

  • remove() 清除值

java
public class ThreadLocalTest {
    static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        new Thread(() -> {
            String name = Thread.currentThread().getName();
            threadLocal.set("itcast");
            print(name);
            System.out.println(name + "-after remove : " + threadLocal.get());
        }, "t1").start();
        new Thread(() -> {
            String name = Thread.currentThread().getName();
            threadLocal.set("itheima");
            print(name);
            System.out.println(name + "-after remove : " + threadLocal.get());
        }, "t2").start();
    }

    static void print(String str) {
        //打印当前线程中本地内存中本地变量的值
        System.out.println(str + " :" + threadLocal.get());
        //清除本地内存中的本地变量
        threadLocal.remove();
    }

}

ThreadLocal的实现原理&源码解析

ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离

image-20230505224341410

在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap

ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置

set方法

image-20230505224626253

get方法/remove方法

image-20230505224715087

ThreadLocal-内存泄露问题

Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用

  • 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收

image-20230505224755797

  • 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收

image-20230505224812015

每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本

image-20230505224857538

在使用ThreadLocal的时候,强烈建议:务必手动remove

面试官:谈谈你对ThreadLocal的理解

候选人

嗯,是这样的~~

ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享

面试官:好的,那你知道ThreadLocal的底层原理实现吗?

候选人

嗯,知道一些~

在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值

当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

面试官:好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?

候选人

嗯,我之前看过源码,我想一下~~

是应为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。

在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。