并发编程多线程

image-20240914152004854

1.线程和进程的区别?

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

2. 并行和并发有什么区别

并发(concurrent)是同一时间应对(dealing with)多件事情的能力,比如:多个线程轮流使用一个或多个CPU

并行(parallel)是同一时间动手做(doing)多件事情的能力,比如:4核CPU同时执行4个线程

举例:

  • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
  • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行

3. 创建线程的四种方式

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

详细创建方式参考下面代码:

继承Thread类

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接口

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接口

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);

    }

}

线程池创建线程

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();

    }

}

4. runnable 和 callable 有什么区别

  1. Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
  2. Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
  3. Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛

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

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

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

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

image-20240919202249426

状态之间是如何变化的

image-20230503203629212

分别是

  • 新建
    • 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
    • 此时未与操作系统底层线程关联
  • 可运行
    • 调用了 start 方法,就会由新建进入可运行
    • 此时与底层线程关联,由操作系统调度执行
  • 终结
    • 线程内代码已经执行完毕,由可运行进入终结
    • 此时会取消与底层线程关联
  • 阻塞
    • 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
    • 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
  • 等待
    • 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
  • 有时限等待
    • 调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态

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

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

代码举例:

为了确保三个线程的顺序你应该先启动最后一个(T3 调用T2,T2调用T1),这样T1就会先完成而T3最后完成

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();

    }

}

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

notifyAll:唤醒所有wait的线程

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

9. 在 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 对象的锁(搭配synchronized一起用),而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

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

有三种方式可以停止线程

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

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

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方法强行终止

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方法中断线程

  • 打断阻塞线程

    Thread t1 = new Thread(() -> {
        System.out.println("t1 正在运行...");
        try {
            Thread.sleep(5000);  // 线程t1进入阻塞状态
        } catch (InterruptedException e) {
            e.printStackTrace();  // 中断时会抛出异常并进入这里
        }
    }, "t1");
    t1.start();
    Thread.sleep(500);  // 主线程等待0.5秒,保证t1已经进入阻塞状态
    t1.interrupt();  // 打断线程t1
    System.out.println(t1.isInterrupted());  // 打印t1的中断状态
    
    t1 正在运行...
    java.lang.InterruptedException: sleep interrupted
    	at java.lang.Thread.sleep(Native Method)
    	at com.itheima.basic.MyInterrupt3.lambda$main$0(MyInterrupt3.java:8)
    	at java.lang.Thread.run(Thread.java:748)
    true
    

    解释:

    • 线程 t1sleep(5000) 时进入了阻塞状态。主线程在 0.5 秒后调用了 t1.interrupt(),导致 t1 被唤醒并抛出 InterruptedException
    • 捕获异常后,t1 结束了执行,并且我们通过 t1.isInterrupted() 打印了中断状态,该方法返回 true,表示 t1 确实被中断过。
  • 打断正常线程

    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);  // 主线程等待0.5秒后中断t2
    t2.interrupt();  // 如果此行取消注释,将打断t2
    
    打断状态:true
    

    解释:

    • 线程 t2 在循环中检测 isInterrupted() 的状态。一旦 t2.interrupt() 被调用,isInterrupted() 返回 true,并打印中断状态,随后线程 t2 退出循环。

11. 讲一下synchronized关键字的底层原理?

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

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

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 被翻译为监视器,是由jvm提供,c++语言实现

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

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状态的线程

12. synchronized关键字的底层原理-进阶

Monitor实现的锁属于重量级锁,你了解过锁升级吗?

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

  • 在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

2.2.1 对象的内存结构

在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充

image-20230504172253826

我们需要重点分析MarkWord对象头

2.2.2 MarkWord

image-20230504172541922

  • hashcode:25位的对象标识Hash码

  • 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表示重量级锁
2.2.3 再说Monitor重量级锁

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针

image-20230504172957271

简单说就是:每个对象的对象头都可以设置monoitor的指针,让对象与monitor产生关联

2.2.4 轻量级锁

在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

static final Object obj = new Object();

public static void method1() {
    synchronized (obj) {
        // 同步块 A
        method2();
    }
}

public static void method2() {
    synchronized (obj) {
        // 同步块 B
    }
}

加锁的流程

1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。

image-20230504173520412

2.通过CAS指令将Lock Record的地址存储在对象头的mark word中(数据进行交换),如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。

image-20230504173611219

3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。

image-20230504173922343

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

解锁过程

1.遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。

2.如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。

image-20230504173955680

3.如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。

image-20230504174045458

2.2.5 偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现

这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有

static final Object obj = new Object();

public static void m1() {
    synchronized (obj) {
        // 同步块 A
        m2();
    }
}

public static void m2() {
    synchronized (obj) {
        // 同步块 B
        m3();
    }
}

public static void m3() {
    synchronized (obj) {

    }
}

加锁的流程

1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。

image-20230504174525256

2.通过CAS指令将Lock Record的线程id存储在对象头的mark word中,同时也设置偏向锁的标识为101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁。

image-20230504174505031

3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。与轻量级锁不同的时,这里不会再次进行cas操作,只是判断对象头中的线程id是否是自己,因为缺少了cas操作,性能相对轻量级锁更好一些

image-20230504174736226

解锁流程参考轻量级锁

2.2.6 参考回答

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

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

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

13. 你谈谈 JMM(Java 内存模型)

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

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

image-20230504181638237

特点:

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

14. 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 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令

image-20230504182737931

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

image-20230504182838426

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

  • ReentrantLock中的一段CAS代码

image-20230504182958703

image-20240920113338029

15. 乐观锁和悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

16. 请谈谈你对 volatile 的理解

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

2.5.1 保证线程间的可见性

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

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

package com.itheima.basic;


// 可见性例子
// -Xint
public class ForeverLoop {
    static boolean stop = false;

    public static void main(String[] args) {
        new Thread(() -> {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            stop = true;
            System.out.println("modify stop to true...");
        }).start();
        foo();
    }

    static void foo() {
        int i = 0;
        while (!stop) {
            i++;
        }
        System.out.println("stopped... c:"+ i);
    }
}

当执行上述代码的时候,发现foo()方法中的循环是结束不了的,也就说读取不到共享变量的值结束循环。

主要是因为在JVM虚拟机中有一个JIT(即时编辑器)给代码做了优化。

上述代码

while (!stop) {
i++;
}

在很短的时间内,这个代码执行的次数太多了,当达到了一个阈值,JIT就会优化此代码,如下:

while (true) {
i++;
}

当把代码优化成这样子以后,及时stop变量改变为了false也依然停止不了循环

解决方案:

第一:

在程序运行的时候加入vm参数-Xint表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用)

第二:

在修饰stop变量的时候加上volatile,表示当前代码禁用了即时编辑器,问题就可以解决,代码如下:

static volatile boolean stop = false;
2.5.2 禁止进行指令重排序

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

image-20230505082441116

在去获取上面的结果的时候,有可能会出现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修饰的变量的在代码最开始位置

17. 什么是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是公平锁吗,还是非公平锁?

image-20240920153705584

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

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

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

18. 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的时候,则会唤醒在双向队列中等待的线程

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

19. synchronized和Lock有什么区别 ?

  • 语法层面

    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现

    • Lock 是接口,源码由 jdk 提供,用 java 语言实现

    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁

  • 功能层面

    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能

    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量

      • 获取等待状态
        Lock 可以提供当前线程的锁状态信息,synchronized 无法实现。例子(通过 ReentrantLock 获取等待线程数):

        import java.util.concurrent.locks.ReentrantLock;
        
        public class LockExample {
            private final ReentrantLock lock = new ReentrantLock();
        
            public void getLockInfo() {
                System.out.println("有多少线程在等待这个锁:" + lock.getQueueLength());
            }
        }
        
      • 公平锁Lock 可以设置公平锁,确保线程按请求顺序获取锁,而 synchronized 是非公平锁。

        import java.util.concurrent.locks.ReentrantLock;
        
        public class FairLockExample {
            private final ReentrantLock fairLock = new ReentrantLock(true);  // true 表示公平锁
        
            public void fairLockMethod() {
                fairLock.lock();
                try {
                    // 公平锁确保线程按请求顺序执行
                } finally {
                    fairLock.unlock();
                }
            }
        }
        
        
      • 可打断Lock 提供了可中断的锁机制,允许在获取锁时响应中断,而 synchronized 无法中断等待锁的线程。例子(使用 lockInterruptibly() 让线程在等待锁时能被中断):

        import java.util.concurrent.locks.ReentrantLock;
        
        public class InterruptibleLockExample {
            private final ReentrantLock lock = new ReentrantLock();
        
            public void lockWithInterrupt() throws InterruptedException {
                lock.lockInterruptibly();  // 等待锁时可以被中断
                try {
                    // do something
                } finally {
                    lock.unlock();
                }
            }
        }
        
        

        可打断详细解释:

        什么是“可打断”?

        假设你正在排队买票,突然接到一个紧急电话,你可能需要离开队伍,去处理电话。但如果你使用的是 synchronized,那你就不能“离开队伍”,只能一直等到轮到你为止。而使用 lockInterruptibly(),就像排队时你可以被“叫走”,即在等待的过程中可以被中断,去做别的事情。

        基本思路

        • synchronized:如果你在等待锁,你必须等到前面的任务完成释放锁,期间不能“被叫走”(不能中断)。
        • lockInterruptibly():如果你在等待锁,可以在等待过程中被“叫走”(被中断),去处理别的紧急任务。

        更简单的例子

        假设有两个小朋友要玩同一个玩具:

        1. 小朋友 A 拿到了玩具,正在玩(相当于获取了锁)。
        2. 小朋友 B 也想玩,但只能等 A 玩完了(等待锁)。
        3. B 的妈妈打电话给他,要他先回家吃饭(中断请求)。

        如果 B 使用的是 synchronized,他必须等 A 玩完才能离开,哪怕妈妈在催他回家。 但如果 B 使用的是 lockInterruptibly(),他可以在等待的时候听妈妈的话,直接离开去吃饭,不用一直等着玩具。

        代码解释

        这里用简单的代码演示“可打断”的行为:

        import java.util.concurrent.locks.ReentrantLock;
        
        public class SimpleInterruptExample {
            private final ReentrantLock lock = new ReentrantLock();
        
            // 模拟小朋友玩玩具的方法
            public void play() throws InterruptedException {
                // 使用 lockInterruptibly() 可以在等待时被中断
                lock.lockInterruptibly();
                try {
                    System.out.println(Thread.currentThread().getName() + " 拿到了玩具,正在玩...");
                    Thread.sleep(5000);  // 模拟玩耍5秒
                } finally {
                    lock.unlock();  // 玩完玩具后释放锁
                    System.out.println(Thread.currentThread().getName() + " 玩完了,离开了玩具。");
                }
            }
        
            public static void main(String[] args) {
                SimpleInterruptExample example = new SimpleInterruptExample();
        
                // 创建两个小朋友的线程
                Thread child1 = new Thread(() -> {
                    try {
                        example.play();
                    } catch (InterruptedException e) {
                        System.out.println(Thread.currentThread().getName() + " 被叫回家吃饭!");
                    }
                }, "小朋友A");
        
                Thread child2 = new Thread(() -> {
                    try {
                        example.play();
                    } catch (InterruptedException e) {
                        System.out.println(Thread.currentThread().getName() + " 被叫回家吃饭!");
                    }
                }, "小朋友B");
        
                // 小朋友A 先拿到玩具开始玩
                child1.start();
        
                // 等A开始玩之后,B也想玩,但会被叫回家吃饭(中断)
                try {
                    Thread.sleep(100);  // 让小朋友A先拿到玩具
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
        
                child2.start();
        
                // 模拟妈妈打电话让小朋友B回家(中断线程B)
                child2.interrupt();  // B 在等待时收到中断信号
            }
        }
        
        
        运行结果:

        小朋友A 拿到了玩具,正在玩…
        小朋友B 被叫回家吃饭!
        小朋友A 玩完了,离开了玩具。

        解读:
        • 小朋友A 拿到了玩具,开始玩。
        • 小朋友B 想玩,但是因为玩具被 A 拿着,只能等。这时,妈妈打电话让 B 回家, B 就被中断了,不再等玩具,直接回家(退出了等待)。
        • A 玩完了,玩具被释放,但 B 已经不需要了。

        总结

        • lockInterruptibly() 就像排队时你可以被“叫走”,去处理更紧急的事情。即使还没等到锁,也可以中途退出。
        • 如果你使用的是 synchronized,就只能一直等到锁释放,不能在等待过程中响应其他事件。
      • 可超时Lock 提供了超时机制,允许线程尝试在一定时间内获取锁,超时后可放弃等待,synchronized 没有这个功能。例子(使用 tryLock(long time, TimeUnit unit) 设定超时时间):

        import java.util.concurrent.TimeUnit;
        import java.util.concurrent.locks.ReentrantLock;
        
        public class TimeoutLockExample {
            private final ReentrantLock lock = new ReentrantLock();
        
            public void lockWithTimeout() throws InterruptedException {
                if (lock.tryLock(3, TimeUnit.SECONDS)) {  // 尝试获取锁,最多等待3秒
                    try {
                        // 成功获取锁后执行
                    } finally {
                        lock.unlock();
                    }
                } else {
                    System.out.println("超时未能获取锁");
                }
            }
        }
        
        
      • 多条件变量
        Lock 支持多个 Condition 对象,每个条件变量可以独立地管理线程的等待和唤醒,而 synchronized 只能通过 wait()notify() 对所有线程进行管理。例子(使用 Condition 实现多个条件变量):

        import java.util.concurrent.locks.Condition;
        import java.util.concurrent.locks.ReentrantLock;
        
        public class MultiConditionExample {
            private final ReentrantLock lock = new ReentrantLock();
            private final Condition condition1 = lock.newCondition();
            private final Condition condition2 = lock.newCondition();
        
            public void waitOnCondition1() throws InterruptedException {
                lock.lock();
                try {
                    condition1.await();  // 等待 condition1 被唤醒
                } finally {
                    lock.unlock();
                }
            }
        
            public void signalCondition1() {
                lock.lock();
                try {
                    condition1.signal();  // 唤醒等待在 condition1 上的线程
                } finally {
                    lock.unlock();
                }
            }
        }
        
        
      • ReentrantReadWriteLock:适用于读多写少的场景,支持读写锁分离。多个线程可以同时获取读锁,但写锁是独占的。例子(使用 ReentrantReadWriteLock 实现读写锁分离):

        import java.util.concurrent.locks.ReentrantReadWriteLock;
        
        public class ReadWriteLockExample {
            private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
            private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
            private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
        
            public void readMethod() {
                readLock.lock();
                try {
                    // 多线程可以同时读取
                } finally {
                    readLock.unlock();
                }
            }
        
            public void writeMethod() {
                writeLock.lock();
                try {
                    // 写操作,只有一个线程能写
                } finally {
                    writeLock.unlock();
                }
            }
        }
        
  • 性能层面

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

20. 死锁产生的条件是什么?

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

  1. 互斥条件(Mutual Exclusion):至少有一个资源必须处于非共享状态,即一次只能被一个进程或线程占用。

  2. 请求与保持条件(Hold and Wait):进程或线程至少需要持有一个资源,并且在等待其他资源时不释放已占有的资源。

  3. 不可剥夺条件(No Preemption):已分配给进程或线程的资源不能被强制性地剥夺,只能由持有资源的进程或线程主动释放。

  4. 循环等待条件(Circular Wait):存在一个进程或线程的资源申请序列,使得每个进程或线程都在等待下一个进程或线程所持有的资源。

例如:

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

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

代码如下:

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的锁。

如何进行死锁诊断:

当程序出现了死锁现象,我们可以使用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就行

21. 聊一下ConcurrentHashMap

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

底层数据结构:

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

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

(1) JDK1.7中concurrentHashMap

数据结构

image-20230505092654811

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

存储流程

image-20230505093055382

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

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

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

  • CAS控制数组节点的添加

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

image-20230505093507265

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

22. Java程序中怎么保证多线程的执行安全

Java并发编程三大特性

  • 原子性

  • 可见性

  • 有序性

(1)原子性

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

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

image-20230505205200628

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

解决方案:

1.synchronized:同步加锁

2.JUC里面的lock:加锁

image-20230505210853493

(3)内存可见性

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

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

image-20230505211002252

解决方案:

  • synchronized

  • volatile(推荐)

  • LOCK

(3)有序性

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

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

image-20230505211209336

解决方案:

  • volatile

23. 说一下线程池的核心参数(线程池的执行原理知道嘛)

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

image-20230505220514872

  • corePoolSize 核心线程数目

  • maximumPoolSize 最大线程数目 = (核心线程+临时线程的最大数目)

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

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

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

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

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

工作流程

image-20230505220701835

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

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

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

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

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

拒绝策略:

在这里插入图片描述

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

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

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

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

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

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

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

ArrayBlockingQueue的LinkedBlockingQueue区别

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

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

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

image-20230505221424359

25. 如何确定核心线程数

在这里插入图片描述

  • IO 密集型任务:建议使用 CPU 核数 * 2 的线程数。因为 IO 密集型任务在等待外部资源(如网络、磁盘等)时,CPU 处于空闲状态。增加线程数可以在某些线程等待 I/O 时,其他线程继续执行任务,充分利用 CPU 资源。
  • 计算密集型任务:建议使用 CPU 核数 + 1 的线程数。因为计算密集型任务完全占用 CPU,线程数等于 CPU 核数能最大化资源利用。多一个线程可以在偶尔的线程切换或延迟时,保持 CPU 始终有工作,减少空闲时间。

26. 线程池的种类有哪些

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

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

    image-20230505221959259

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

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

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

    • 案例:

      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();
          }
      
      }
      
  2. 单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行

    image-20230505222050294

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

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

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

    • 案例:

      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();
          }
      
      }
      
  3. 可缓存线程池

    image-20230505222126391

    • 核心线程数为0

    • 最大线程数是Integer.MAX_VALUE

    • 阻塞队列为SynchronousQueue:不存储元素的阻塞队列。

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

    • 案例:

      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();
          }
      
      }
      
  4. 提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。

    image-20230505222203615

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

    • 案例:

      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();
      
          }
      
      }
      

image-20240922135929244

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

参考阿里开发手册《Java开发手册-嵩山版》,可能会导致内存溢出,因为设置的最大线程数为Integer最大值或者阻塞队列没有上限,导致内存溢出Out Of Memory

简单来说,要么是最大线程无穷大,要么是阻塞队列无穷大

image-20220821003816845

28. 线程池使用场景Future

1. 多个服务独立运行

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

image-20230505223442924

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

  • 报表汇总

    image-20230505223536657

2. 异步调用(避免下一级方法影响上一级方法:性能考虑)

1. 开启异步功能

首先,仍然需要启用异步功能,和之前一样,在 Spring Boot 的主类上添加 @EnableAsync 注解:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class AsyncApplication {

    public static void main(String[] args) {
        SpringApplication.run(AsyncApplication.class, args);
    }
}
2. 创建自定义 ThreadPoolExecutor 线程池

我们将创建 ThreadPoolExecutor 并返回它作为 Executor 接口的实现。我们需要手动配置线程池的核心线程数、最大线程数、队列大小等。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        // 创建ThreadPoolExecutor
        return new ThreadPoolExecutor(
                5,  // 核心线程数
                10, // 最大线程数
                60, // 线程空闲时间
                TimeUnit.SECONDS, // 时间单位
                new LinkedBlockingQueue<>(100), // 任务队列
                new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
        );
    }
}
3. 使用异步方法

接下来,在需要异步执行的方法上依然使用 @Async 注解,并指定线程池为我们自定义的 taskExecutor

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class AsyncService {

    @Async("taskExecutor")
    public void executeAsyncTask() {
        System.out.println("异步任务开始,线程名称:" + Thread.currentThread().getName());
        try {
            Thread.sleep(2000); // 模拟耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("异步任务结束,线程名称:" + Thread.currentThread().getName());
    }
}
4. 调用异步方法

在控制器中调用异步服务。和之前一样,当访问 /async-task 时,将触发异步任务,任务将在后台线程池中执行。

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AsyncController {

    private final AsyncService asyncService;

    public AsyncController(AsyncService asyncService) {
        this.asyncService = asyncService;
    }

    @GetMapping("/async-task")
    public String triggerAsyncTask() {
        asyncService.executeAsyncTask();
        return "异步任务已经触发";
    }
}

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

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

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

Semaphore两个重要的方法

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

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

线程任务类:

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();
        }
    }

}

30. 谈谈你对ThreadLocal的理解

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

    • 第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,

    • 第二个是实现了线程内的资源共享

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

    • ThreadLocal的底层原理是基于每个线程都有一个独立的存储区域ThreadLocalMap)来实现的。以下是其核心机制:
      1. ThreadLocalMapThreadLocal的实际数据存储是基于ThreadLocalMap。每个线程对象(Thread)内部都有一个ThreadLocalMap,这个ThreadLocalMap是用来为当前线程存储数据的。
      2. set()方法: 当你调用ThreadLocal.set()时,ThreadLocal对象会作为key,实际的数据作为value,存储在当前线程的ThreadLocalMap中。这个Map的结构是<ThreadLocal, 值>。不同的线程有独立的ThreadLocalMap,因此相同的ThreadLocal对象在不同线程中的值是独立的。
      3. get()方法: 当调用get()时,系统会从当前线程的ThreadLocalMap中,使用ThreadLocal对象作为key查找数据。如果找到key对应的值,则返回该值;如果没有找到,则可以返回初始化的默认值。
      4. remove()方法: 调用remove()会从当前线程的ThreadLocalMap中删除这个ThreadLocal对象对应的键值对,以便释放不再需要的资源,防止内存泄漏。
  • 面试官:那关于ThreadLocal会导致内存溢出这个事情,了解吗?

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值