Java并发编程

 

Java并发编程

1.你对线程安全的理解是什么?

线程安全是并发编程中一个重要的概念,如果一段代码块或者一个方法在多线程环境中被多个线程同时执行时能够正确地处理共享数据,那么这段代码块或者方法就是线程安全的。

可以从三个要素来确保线程安全:

①、原子性:确保当某个线程修改共享变量时,没有其他线程可以同时修改这个变量,即这个操作是不可分割的。

②、可见性:确保一个线程对共享变量的修改可以立即被其他线程看到。

③、活跃性问题:要确保线程不会因为死锁、饥饿、活锁等问题导致无法继续执行。

2.线程和进程的区别?

定义

进程:进程是操作系统中程序的一次执行实例,它是系统进行资源分配和调度的基本单位。每个进程都有独立的地址空间和其他资源(如文件句柄、环境变量等)。

线程:线程是进程内的一个执行流,它是处理器调度和分派的基本单位。同一进程内的线程共享该进程的地址空间和资源。

资源占用

进程拥有独立的内存空间,因此每个进程都有自己的数据段、堆栈段和代码段等,这意味着进程间的资源是隔离的。

线程共享所属进程的数据段、堆栈段和代码段等资源,因此创建线程比创建进程消耗更少的资源。

上下文切换开销

进程间的上下文切换涉及到更多的资源转移和保护,因此开销较大。

线程间的上下文切换仅需保存和恢复少量寄存器值及栈指针,因此开销较小。

通信方式

进程间通信(IPC)通常需要通过操作系统提供的机制来实现,如管道、消息队列、共享内存等,这增加了通信的复杂度。

线程可以直接访问同一进程内的全局变量或数据结构,因此线程间的通信更为简单直接。

生命周期管理

进程的创建和销毁涉及更多资源的初始化和清理工作,因此相对于线程来说更加耗时。

线程的生命周期管理较为轻量级,创建和销毁速度快。

依赖关系

线程依赖于进程的存在,没有进程就没有线程。进程是独立的执行环境,可以不依赖其他进程单独存在。

3.线程共享内存和进程共享内存的区别?

进程共享内存

共享范围

不同进程之间默认情况下是不共享内存的,每个进程都有自己的独立地址空间。进程间的内存共享需要通过特定的技术手段实现,例如通过共享内存段、映射文件等方式。

通信复杂度

进程间通信(IPC)通常需要通过操作系统提供的机制来实现,如管道、消息队列、共享内存、套接字等。这些机制比线程间的直接内存访问要复杂得多。

数据一致性

由于进程间通信需要通过特定的通道进行,因此在设计上更容易实现数据的一致性和安全性。

资源开销

创建进程的资源开销相对较大,因为每个进程都需要自己的虚拟地址空间和系统资源(如文件句柄、环境变量等)。

线程共享内存

共享范围

同一进程内的所有线程共享该进程的整个地址空间,包括代码段、数据段、堆和栈等。

通信复杂度

线程之间的通信非常简单,因为它们可以直接访问同一进程内的全局变量或其他数据结构,无需复杂的同步机制。

数据一致性

虽然共享内存简化了线程间的通信,但也带来了数据一致性和同步的问题,需要通过锁机制(如互斥锁、信号量等)来保证数据访问的原子性和一致性。

资源开销

创建线程的资源开销相对较小,因为不需要额外的内存空间分配,只需为每个线程维护一个栈和一些控制信息即可。

4.有多少种实现线程的方式?

继承Thread类

class ThreadTask extends Thread {
    public void run() {
        System.out.println("继承Thread类");
    }

    public static void main(String[] args) {
        ThreadTask task = new ThreadTask();
        task.start();
    }
}

直接继承Thread类,并重写其run方法。这种方式简单直观,但因为Java不支持多重继承,所以如果需要继承其他类,则不能使用这种方法。

实现Runnable接口

class RunnableTask implements Runnable {
    public void run() {
        System.out.println("实现Runnable接口");
    }

    public static void main(String[] args) {
        RunnableTask task = new RunnableTask();
        Thread thread = new Thread(task);
        thread.start();
    }
}

实现Runnable接口并重写run方法,然后将这个对象传递给Thread类的构造函数创建线程。这种方式更灵活,因为它允许类继承其他类的同时实现多线程功能。

实现Callable接口配合FutureTask使用

class CallableTask implements Callable<String> {
    public String call() {
        return "实现Callable接口";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CallableTask task = new CallableTask();
        FutureTask<String> futureTask = new FutureTask<>(task);
        Thread thread = new Thread(futureTask);
        thread.start();
        System.out.println(futureTask.get());
    }
}

Callable接口类似于Runnable,不同之处在于Callable的call方法可以返回结果,并且可以抛出异常。通过FutureTask包装Callable对象,然后将其传递给Thread类创建线程。这种方式适合需要返回结果的任务。

使用Executor框架和线程池

线程池的应用

5.为什么在项目中使用线程池?

1、频繁地创建和销毁线程会消耗系统资源,线程池能够复用已创建的线程。

2、提高响应速度,当任务到达时,任务可以不需要等待线程创建就立即执行。

3、线程池支持定时执行、周期性执行、单线程执行和并发数控制等功能。

6.讲一讲你对线程池的理解,并讲一讲使用的场景

线程池的概念

线程池是一种管理线程的技术,它预先创建一组线程,并将它们组织在一起,以便能够高效地处理多个任务。线程池的核心思想是复用已创建的线程,而不是每次任务到来时都创建新的线程。

线程池的基本组成

核心线程数(Core Pool Size):线程池中始终维持的最小线程数。

最大线程数(Maximum Pool Size):线程池中允许的最大线程数。

工作队列(Work Queue):用来存储等待执行的任务。

拒绝策略(Rejection Policy):当线程池无法接收更多任务时采取的策略。

线程工厂(Thread Factory):用于创建新线程的对象。

Java中线程池的主要参数

①、corePoolSize

定义了线程池中的核心线程数量。即使这些线程处于空闲状态,它们也不会被回收。这是线程池保持在等待状态下的线程数。

②、maximumPoolSize

线程池允许的最大线程数量。当工作队列满了之后,线程池会创建新线程来处理任务,直到线程数达到这个最大值。

③、keepAliveTime

非核心线程的空闲存活时间。如果线程池中的线程数量超过了 corePoolSize,那么这些多余的线程在空闲时间超过 keepAliveTime 时会被终止。

④、unit

keepAliveTime 参数的时间单位:

TimeUnit.DAYS; 天 TimeUnit.HOURS; 小时 TimeUnit.MINUTES; 分钟 TimeUnit.SECONDS; 秒 TimeUnit.MILLISECONDS; 毫秒 TimeUnit.MICROSECONDS; 微秒 TimeUnit.NANOSECONDS; 纳秒 ⑤、workQueue

用于存放待处理任务的阻塞队列。当所有核心线程都忙时,新任务会被放在这个队列里等待执行。

⑥、threadFactory

一个创建新线程的工厂。它用于创建线程池中的线程。可以通过自定义 ThreadFactory 来给线程池中的线程设置有意义的名字,或设置优先级等。

⑦、handler

拒绝策略 RejectedExecutionHandler,定义了当线程池和工作队列都满了之后对新提交的任务的处理策略。常见的拒绝策略包括抛出异常、直接丢弃、丢弃队列中最老的任务、由提交任务的线程来直接执行任务等。

线程池的工作流程

1.任务提交:当一个任务提交到线程池时,线程池会尝试分配一个线程来执行该任务。

2.核心线程数:如果当前活动线程少于核心线程数,即使有空闲线程,也会创建新的线程来执行任务。

3.工作队列:如果当前活动线程等于核心线程数,但还有任务需要执行,那么这些任务会被放入工作队列中等待执行。

4.最大线程数:如果工作队列已满,线程池会尝试创建新的线程,直到达到最大线程数。

5.拒绝策略:如果线程池已经达到最大线程数且工作队列已满,线程池将根据拒绝策略处理新任务。

线程池的拒绝策略

AbortPolicy:

这是默认的拒绝策略。当线程池无法接受新任务时,它会抛出一个RejectedExecutionException异常。这通常意味着应用程序需要处理这个异常,并可能需要采取补救措施,比如记录日志或者通知管理员。

CallerRunsPolicy:

当线程池无法接受新任务时,这个策略会让调用者所在的线程来运行这个任务。如果调用者的线程本身已经在执行其他任务,那么可能会导致调用者线程的阻塞。这种策略适合于并发度不高、性能要求不是特别高的场景。

DiscardPolicy:

当线程池无法接受新任务时,这个策略会直接丢弃任务而不执行它,也不会抛出异常。这种策略适用于那些可以容忍任务丢失的场景。

DiscardOldestPolicy:

当线程池无法接受新任务时,这个策略会首先丢弃队列中最旧的任务,然后尝试再次提交新任务。这种策略有助于优先处理最新的任务,但可能导致某些任务永远无法被执行。

自定义拒绝策略

除了这些内置的拒绝策略之外,还可以通过实现RejectedExecutionHandler接口来自定义拒绝策略,以适应特定的应用需求。

线程池的阻塞队列

ArrayBlockingQueue:

一个由数组结构组成的有界阻塞队列。

按照先进先出(FIFO)排序元素。

是LinkedBlockingQueue的一个替代品,当需要一个容量固定的队列时使用。

LinkedBlockingQueue:

一个基于链表结构的阻塞队列,吞吐量通常要高于ArrayBlockingQueue。

默认情况下是无界的,但是可以通过构造函数指定队列长度。

适用于需要一个具有较高吞吐量的无界或有限阻塞队列的情况

PriorityBlockingQueue:

一个具有优先级的无界阻塞队列。

支持优先级排序的功能,可以按照优先级来决定哪个任务先被执行。

适用于需要根据任务优先级来调度执行的任务队列。

DelayQueue:

一个使用Delayed元素的无界阻塞队列。

队列中的元素只有在其延迟过期后才能被消费者线程获取。

适用于需要延迟执行的任务。

SynchronousQueue:

一个不存储元素的阻塞队列。

每个插入操作必须等待另一个线程的相应移除操作,反之亦然。

适用于传递元素,而不是存储元素的情况,通常用于实现生产者-消费者模型。

线程池的提交

execute(Runnable command)

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建线程池
        ExecutorService executor = Executors.newFixedThreadPool(5);

        // 提交任务
        for (int i = 0; i < 10; i++) {
            int taskId = i;
            executor.execute(() -> {
                System.out.println("Task " + taskId + " is running on thread " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });

            // 关闭线程池
            executor.shutdown();
            try {
                if (!executor.awaitTermination(1, TimeUnit.MINUTES)) {
                    executor.shutdownNow();
                }
            } catch (InterruptedException e) {
                executor.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }
    }
}

execute方法用于提交一个Runnable任务,是最基本的提交方式。它没有返回值,也不支持获取任务执行结果。

submit(Runnable task)

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建线程池
        ExecutorService executor = Executors.newFixedThreadPool(5);

        // 提交任务
        for (int i = 0; i < 10; i++) {
            int taskId = i;
            Future<?> future = executor.submit(() -> {
                System.out.println("Task " + taskId + " is running on thread " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });

            // 关闭线程池
            executor.shutdown();
            try {
                if (!executor.awaitTermination(1, TimeUnit.MINUTES)) {
                    executor.shutdownNow();
                }
            } catch (InterruptedException e) {
                executor.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }
    }
}

submit方法用于提交一个Runnable任务,并返回一个Future对象,可以用来获取任务的执行状态和结果。

线程池的关闭

可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。

shutdown() 将线程池状态置为 shutdown,并不会立即停止:

停止接收外部 submit 的任务 内部正在跑的任务和队列里等待的任务,会执行完 等到第二步完成后,才真正停止

shutdownNow() 将线程池状态置为 stop。一般会立即停止,事实上不一定:

和 shutdown()一样,先停止接收外部提交的任务 忽略队列里等待的任务 尝试将正在跑的任务 interrupt 中断 返回未执行的任务列表

shutdown 和 shutdownnow 简单来说区别如下:

shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。

shutdown()只是关闭了提交通道,用 submit()是无效的;而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池。

线程池的线程数配置

①、对于 CPU 密集型任务,我的目标是尽量减少线程上下文切换,以优化 CPU 使用率。一般来说,核心线程数设置为处理器的核心数或核心数加一(以备不时之需,如某些线程因等待系统资源而阻塞时)是较理想的选择。

②、对于 IO 密集型任务,由于线程经常处于等待状态(等待 IO 操作完成),可以设置更多的线程来提高并发性(比如说 2 倍),从而增加 CPU 利用率。

线程池的种类

newFixedThreadPool (固定线程数目的线程池)

public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>(),
                                  threadFactory);
}

线程池特点

核心线程数和最大线程数大小一样

没有所谓的非空闲时间,即 keepAliveTime 为 0

阻塞队列为无界队列 LinkedBlockingQueue,可能会导致 OOM

工作流程

提交任务

如果线程数少于核心线程,创建核心线程执行任务

如果线程数等于核心线程,把任务添加到 LinkedBlockingQueue 阻塞队列

如果线程执行完任务,去阻塞队列取任务,继续执行。

适用场景

FixedThreadPool 适用于处理 CPU 密集型的任务,确保 CPU 在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。

newCachedThreadPool (可缓存线程的线程池)

public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>(),
                                  threadFactory);
}

线程池特点

核心线程数为 0

最大线程数为 Integer.MAX_VALUE,即无限大,可能会因为无限创建线程,导致 OOM

阻塞队列是 SynchronousQueue

非核心线程空闲存活时间为 60 秒

当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端情况下会创建过多的线程,耗尽 CPU 和内存资源。由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。

工作流程

提交任务

因为没有核心线程,所以任务直接加到 SynchronousQueue 队列。

判断是否有空闲线程,如果有,就去取出任务执行。

如果没有空闲线程,就新建一个线程执行。

执行完任务的线程,还可以存活 60 秒,如果在这期间,接到任务,可以继续活下去;否则,被销毁。

适用场景

用于并发执行大量短期的小任务。

newSingleThreadExecutor (单线程的线程池)

public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>(),
                                threadFactory));
}

线程池特点

核心线程数为 1

最大线程数也为 1

阻塞队列是无界队列 LinkedBlockingQueue,可能会导致 OOM

keepAliveTime 为 0

工作流程

提交任务

线程池是否有一条线程在,如果没有,新建线程执行任务

如果有,将任务加到阻塞队列

当前的唯一线程,从队列取任务,执行完一个,再继续取,一个线程执行任务。

适用场景

适用于串行执行任务的场景,一个任务一个任务地执行。

newScheduledThreadPool (定时及周期执行的线程池)

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

线程池特点

最大线程数为 Integer.MAX_VALUE,也有 OOM 的风险

阻塞队列是 DelayedWorkQueue

keepAliveTime 为 0

scheduleAtFixedRate() :按某种速率周期执行

scheduleWithFixedDelay():在某个延迟后执行

工作机制

线程从 DelayQueue 中获取已到期的 ScheduledFutureTask(DelayQueue.take())。到期任务是指 ScheduledFutureTask 的 time 大于等于当前时间。

线程执行这个 ScheduledFutureTask。

线程修改 ScheduledFutureTask 的 time 变量为下次将要被执行的时间。

线程把这个修改 time 之后的 ScheduledFutureTask 放回 DelayQueue 中(DelayQueue.add())。

适用场景

周期性执行任务的场景,需要限制线程数量的场景

线程池异常处理

1.try-catch 捕获异常

2.submit执行,Feture.get接受异常

3.重写ThreadPoolExecutor.afterExecute方法,处理传递的异常引用

4.实例化时,传入自己的ThreadFactory,设置Thread.UncaughtExceptionHandler处理未检测的异常

线程池的状态

ThreadPoolExecutor 类使用一个名为 ctl 的原子变量来存储线程池的状态信息。这个变量是一个 long 类型的值,其中一部分位用于表示线程池的状态,另一部分位用于表示线程池中的活动线程数。ctl 变量的低三位用于表示线程池的状态,共有四种状态:

RUNNING

该状态的线程池会接收新任务,并处理阻塞队列中的任务;

调用线程池的 shutdown()方法,可以切换到 SHUTDOWN 状态;

调用线程池的 shutdownNow()方法,可以切换到 STOP 状态;

这是线程池的初始状态

SHUTDOWN

该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;

队列为空,并且线程池中执行的任务也为空,进入 TIDYING 状态;

STOP

该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;

线程池中执行的任务为空,进入 TIDYING 状态;

TIDYING

该状态表明所有的任务已经运行终止,记录的任务数量为 0。

terminated()执行完毕,进入 TERMINATED 状态

当最后一个任务完成后,线程池会进入 TIDYING 状态。

TERMINATED

线程池已经完成所有清理工作,处于终止状态。

线程池对参数的动态修改

在我们微服务的架构下,可以利用配置中心如 Nacos、Apollo 等等,也可以自己开发配置中心。业务服务读取线程池配置,获取相应的线程池实例来修改线程池的参数。

如果限制了配置中心的使用,也可以自己去扩展ThreadPoolExecutor,重写方法,监听线程池参数变化,来动态修改线程池参数。

7. 线程池在使用时需要注意什么?

①、选择合适的线程池大小

过小的线程池可能会导致任务一直在排队

过大的线程池可能会导致大家都在竞争 CPU 资源,增加上下文切换的开销

可以根据业务是 IO 密集型还是 CPU 密集型来选择线程池大小:

CPU 密集型:指的是任务主要使用来进行大量的计算,没有什么导致线程阻塞。一般这种场景的线程数设置为 CPU 核心数+1。

IO 密集型:当执行任务需要大量的 io,比如磁盘 io,网络 io,可能会存在大量的阻塞,所以在 IO 密集型任务中使用多线程可以大大地加速任务的处理。一般线程数设置为 2*CPU 核心数。

②、任务队列的选择

使用有界队列可以避免资源耗尽的风险,但是可能会导致任务被拒绝

使用无界队列虽然可以避免任务被拒绝,但是可能会导致内存耗尽

一般需要设置有界队列的大小,比如 LinkedBlockingQueue 在构造的时候可以传入参数来限制队列中任务数据的大小,这样就不会因为无限往队列中扔任务导致系统的 oom。

③、尽量使用自定义的线程池,而不是使用 Executors 创建的线程池,因为 newFixedThreadPool 线程池由于使用了 LinkedBlockingQueue,队列的容量默认无限大,实际使用中出现任务过多时会导致内存溢出;

newCachedThreadPool 线程池由于核心线程数无限大,当任务过多的时候会导致创建大量的线程,可能机器负载过高导致服务宕机。

8.你能设计并实现一个线程池吗?

核心流程

线程池中有 N 个工作线程

把任务提交给线程池运行

如果线程池已满,把任务放入队列

最后当有空闲时,获取队列中任务来执行

代码示例

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class SimpleThreadPool {

    private final BlockingQueue<Runnable> workQueue;
    private final int corePoolSize;
    private final int maximumPoolSize;
    private final long keepAliveTime;
    private final TimeUnit unit;
    private volatile boolean isShutdown = false;

    private final AtomicInteger activeThreads = new AtomicInteger(0);
    private final ThreadFactory threadFactory;

    public SimpleThreadPool(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, ThreadFactory threadFactory) {
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.keepAliveTime = keepAliveTime;
        this.unit = unit;
        this.threadFactory = threadFactory;
        this.workQueue = new LinkedBlockingQueue<>();
    }

    public void execute(Runnable command) {
        if (isShutdown) {
            throw new IllegalStateException("Executor has been shutdown");
        }

        // 尝试将任务放入队列
        if (workQueue.offer(command)) {
            addWorker();
        } else {
            // 队列已满,尝试创建新线程
            startWorker(command);
        }
    }

    private void addWorker() {
        if (activeThreads.get() < corePoolSize) {
            startWorker(null);
        }
    }

    private void startWorker(Runnable firstTask) {
        Thread worker = threadFactory.newThread(new Worker(firstTask));
        worker.start();
    }

    private class Worker implements Runnable {
        private Runnable currentTask;

        public Worker(Runnable firstTask) {
            this.currentTask = firstTask;
        }

        @Override
        public void run() {
            if (currentTask != null) {
                try {
                    currentTask.run();
                } finally {
                    currentTask = null;
                }
            }

            while (!isShutdown) {
                try {
                    currentTask = workQueue.take();
                    currentTask.run();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                } catch (RuntimeException e) {
                    // 处理异常
                    handleException(e);
                }
            }

            activeThreads.decrementAndGet();
        }
    }

    private void handleException(RuntimeException e) {
        System.err.println("Caught exception: " + e.getMessage());
        e.printStackTrace();
    }

    public void shutdown() {
        isShutdown = true;
        // 中断所有空闲线程
        interruptIdleWorkers();
    }

    private void interruptIdleWorkers() {
        for (int i = 0; i < activeThreads.get(); i++) {
            Thread worker = new Thread(() -> {});
            worker.interrupt();
        }
    }

    public static void main(String[] args) {
        SimpleThreadPool executor = new SimpleThreadPool(5, 10, 60, TimeUnit.SECONDS, new CustomThreadFactory("MyThread"));

        for (int i = 0; i < 20; i++) {
            int taskId = i;
            executor.execute(() -> {
                System.out.println("Processing task " + taskId);
                try {
                    Thread.sleep(1000);  // 模拟任务执行时间
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.out.println("Task interrupted");
                }
            });
        }

        // 关闭线程池
        executor.shutdown();
    }
}

class CustomThreadFactory implements ThreadFactory {
    private final AtomicInteger threadNumber = new AtomicInteger(1);
    private final String namePrefix;

    public CustomThreadFactory(String namePrefix) {
        this.namePrefix = namePrefix;
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, namePrefix + threadNumber.getAndIncrement());
        t.setUncaughtExceptionHandler((thread, throwable) -> {
            System.out.println("Caught exception in thread " + thread.getName() + ": " + throwable.getMessage());
            throwable.printStackTrace();
        });
        return t;
    }
}

9.调用 start()方法时会执行 run()方法,那怎么不直接调用 run()方法?

在 Java 中,start() 方法用于启动一个线程,而 run() 方法则用于执行线程的任务。当你调用 start() 方法时,它将启动一个新的线程,并使其运行 run() 方法。

如果直接调用run()方法,那么run()方法就在当前线程中运行,没有新的线程被创建,也就没有实现多线程的效果。

start() 方法的调用会告诉 JVM 准备好所有必要的新线程结构,分配其所需资源,并调用线程的 run() 方法在这个新线程中执行。

10.线程有哪些常用的调度方法?

线程的等待与通知:

①、wait():当一个线程 A 调用一个共享变量的 wait() 方法时,线程 A 会被阻塞挂起,直到发生下面几种情况才会返回 :

线程 B 调用了共享对象 notify()或者 notifyAll() 方法; 其他线程调用了线程 A 的 interrupt() 方法,线程 A 抛出 InterruptedException 异常返回。

②、wait(long timeout) :这个方法相比 wait() 方法多了一个超时参数,它的不同之处在于,如果线程 A 调用共享对象的 wait(long timeout)方法后,没有在指定的 timeout 时间内被其它线程唤醒,那么这个方法还是会因为超时而返回。

③、wait(long timeout, int nanos),其内部调用的是 wait(long timout) 方法。

唤醒线程主要有下面两个方法:

①、notify():一个线程 A 调用共享对象的 notify() 方法后,会唤醒一个在这个共享变量上调用 wait 系列方法后被挂起的线程。

一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。

②、notifyAll():不同于在共享变量上调用 notify() 方法会唤醒被阻塞到该共享变量上的一个线程,notifyAll 方法会唤醒所有在该共享变量上调用 wait 系列方法而被挂起的线程。

Thread 类还提供了一个 join() 方法,意思是如果一个线程 A 执行了 thread.join(),当前线程 A 会等待 thread 线程终止之后才从 thread.join() 返回。

线程休眠

sleep(long millis):Thread 类中的静态方法,当一个执行中的线程 A 调用了 Thread 的 sleep 方法后,线程 A 会暂时让出指定时间的执行权。

但是线程 A 所拥有的监视器资源,比如锁,还是持有不让出的。指定的睡眠时间到了后该方法会正常返回,接着参与 CPU 的调度,获取到 CPU 资源后就可以继续运行。

让出优先权

yield():Thread 类中的静态方法,当一个线程调用 yield 方法时,实际是在暗示线程调度器,当前线程请求让出自己的 CPU,但是线程调度器可能会“装看不见”忽略这个暗示。

线程中断

Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行。被中断的线程会根据中断状态自行处理。

void interrupt() 方法:中断线程,例如,当线程 A 运行时,线程 B 可以调用线程 interrupt() 方法来设置线程的中断标志为 true 并立即返回。设置标志仅仅是设置标志, 线程 B 实际并没有被中断,会继续往下执行。

boolean isInterrupted() 方法: 检测当前线程是否被中断。

boolean interrupted() 方法: 检测当前线程是否被中断,与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志。

为了响应中断,线程的执行代码应该这样编写:

public void run() {
    try {
        while (!Thread.currentThread().isInterrupted()) {
            // 执行任务
        }
    } catch (InterruptedException e) {
        // 线程被中断时的清理代码
    } finally {
        // 线程结束前的清理代码
    }
}

stop 方法用来强制线程停止执行,目前已经处于废弃状态,因为 stop 方法会导致线程立即停止,可能会在不一致的状态下释放锁,破坏对象的一致性,导致难以发现的错误和资源泄漏。

11.线程的生命周期和状态?

新建状态(New)

当创建一个新的线程对象时,线程处于新建状态。此时,JVM已经为线程分配了内存,但尚未开始执行线程。

就绪状态(Runnable)

当调用线程对象的 start() 方法后,线程进入就绪状态。这意味着线程已经准备好被执行,但是还没有被调度器选中占用CPU时间。处于就绪状态的线程被放入可运行池中等待CPU时间片。

运行状态(Running)

当就绪状态的线程被调度器选中并分配了CPU时间片后,线程开始执行其 run() 方法内的代码。此时线程处于运行状态。

阻塞状态(Blocked)

线程由于某些原因暂时停止运行,比如等待I/O操作完成、等待用户输入、等待锁的获取等。阻塞状态下的线程不会占用CPU时间片,只有当阻塞原因解除后,线程才能重新进入就绪状态。

等待状态(Waiting)

线程调用了 Object.wait() 方法或者其他会导致线程等待的方法时,线程会进入等待状态。在此状态下,线程会释放持有的锁,并等待其他线程的通知(通过 notify() 或 notifyAll() 方法)才能继续执行。

定时等待状态(Timed Waiting)

当线程调用了一些具有指定等待时间的方法,如 Thread.sleep()、Object.wait(long timeout) 或 Thread.join(long millis) 时,线程会进入定时等待状态。在指定的时间过后,线程会自动恢复到就绪状态。

死亡状态(Terminated)

当线程执行完毕或因异常退出了 run() 方法后,线程结束其生命周期,进入死亡状态。此时线程不再执行任何操作,也不会被再次调度。

12.什么是线程的上下文切换?

线程的上下文切换是指操作系统在多线程环境中,为了实现线程间的切换而进行的一系列操作。具体来说,当操作系统需要从一个线程切换到另一个线程时,它需要保存当前线程的状态(即上下文信息),然后加载另一个线程的状态,使得后者可以在CPU上继续执行。这个过程称为上下文切换。

上下文切换的过程主要包括以下几个步骤:

保存当前线程的上下文:

记录当前线程的CPU寄存器值(如程序计数器、状态寄存器等)。

保存当前线程的程序状态(如堆栈指针、栈顶指针等)。

更新当前线程的状态信息(如将其标记为就绪或等待状态)。

选择新的线程:

操作系统从就绪队列中选择一个线程作为下一个执行的线程。

恢复新线程的上下文:

加载新线程的CPU寄存器值。

恢复新线程的程序状态。

将新线程的状态更新为运行状态。

上下文切换的影响

开销:上下文切换本身需要消耗时间和CPU资源,包括保存和恢复寄存器、更新任务控制块(TCB)等。频繁的上下文切换会导致额外的开销,从而影响系统的整体性能。

中断:上下文切换通常伴随着中断的发生,这会进一步增加系统的开销。

并发度:虽然上下文切换使得多个线程能够在单个CPU上并发执行,但如果切换过于频繁,反而会降低并发执行的效率。

13.守护线程了解吗?

守护线程(Daemon Thread)是在计算机程序中一种特殊的线程类型,主要用于执行后台任务,而不干扰程序的主要功能。守护线程的特点是它们的存在是为了服务其他线程或整个应用程序,而不是直接为用户提供服务。当所有的非守护线程(也称作用户线程)都结束执行后,Java虚拟机(JVM)会自动终止所有守护线程并退出程序,即使守护线程仍在运行中。

守护线程的特点:

生命周期

守护线程的生命周期与应用程序的主线程(或非守护线程)紧密相关。当所有的非守护线程都终止时,即使还有守护线程在运行,虚拟机也会认为程序已经不再需要继续执行,并会停止所有守护线程,然后退出程序。

服务性质

守护线程通常用于执行那些不需要用户交互、对结果不敏感,且在程序运行过程中持续进行的后台任务。例如,垃圾回收(GC)线程、日志记录线程、监控线程、定时任务线程等。

创建与设置

在Java中,线程默认创建为非守护线程。若要将其设置为守护线程,需要在创建线程后,通过 Thread.setDaemon(true) 方法进行设置。注意,只能在启动线程之前设置线程为守护线程。

退出行为

当主线程或最后一个非守护线程结束时,即使守护线程还在运行(如循环未结束、阻塞在 I/O 操作等),JVM 也会强制终止守护线程,不会等待其自然结束。因此,守护线程不应该持有任何需要在程序退出时释放的重要资源,也不应该执行任何必须在程序退出前完成的清理工作。

异常处理

如果守护线程抛出了未捕获的异常,且没有设置默认的未捕获异常处理器,那么该异常会被忽略,并且会导致守护线程立即终止。这与非守护线程不同,非守护线程抛出未捕获异常通常会导致整个程序终止。

14.线程间的通信方式?

①、使用共享对象,多个线程可以访问和修改同一个对象,从而实现信息的传递,比如说 volatile 和 synchronized 关键字。

关键字 volatile 用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,保证所有线程对变量访问的可见性。

关键字 synchronized 可以修饰方法,或者以同步代码块的形式来使用,确保多个线程在同一个时刻,只能有一个线程在执行某个方法或某个代码块。

public class SharedObject {
    private String message;
    private boolean hasMessage = false;

    public synchronized void writeMessage(String message) {
        while (hasMessage) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        this.message = message;
        hasMessage = true;
        notifyAll();
    }

    public synchronized String readMessage() {
        while (!hasMessage) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        hasMessage = false;
        notifyAll();
        return message;
    }
}

public class Main {
    public static void main(String[] args) {
        SharedObject sharedObject = new SharedObject();

        Thread writer = new Thread(() -> {
            sharedObject.writeMessage("Hello from Writer!");
        });

        Thread reader = new Thread(() -> {
            String message = sharedObject.readMessage();
            System.out.println("Reader received: " + message);
        });

        writer.start();
        reader.start();
    }
}

②、使用 wait() 和 notify(),例如,生产者-消费者模式中,生产者生产数据,消费者消费数据,通过 wait() 和 notify() 方法可以实现生产和消费的协调。

一个线程调用共享对象的 wait() 方法时,它会进入该对象的等待池,并释放已经持有的该对象的锁,进入等待状态,直到其他线程调用相同对象的 notify() 或 notifyAll() 方法。

一个线程调用共享对象的 notify() 方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁。

Condition 也提供了类似的方法,await() 负责等待、signal() 和 signalAll() 负责通知。

通常与锁(特别是 ReentrantLock)一起使用,为线程提供了一种等待某个条件成真的机制,并允许其他线程在该条件变化时通知等待线程。更灵活、更强大。

class MessageBox {
    private String message;
    private boolean empty = true;

    public synchronized void produce(String message) {
        while (!empty) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        empty = false;
        this.message = message;
        notifyAll();
    }

    public synchronized String consume() {
        while (empty) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        empty = true;
        notifyAll();
        return message;
    }
}

public class Main {
    public static void main(String[] args) {
        MessageBox box = new MessageBox();

        Thread producer = new Thread(() -> {
            box.produce("Message from producer");
        });

        Thread consumer = new Thread(() -> {
            String message = box.consume();
            System.out.println("Consumer received: " + message);
        });

        producer.start();
        consumer.start();
    }
}

③、使用 Exchanger,Exchanger 是一个同步点,可以在两个线程之间交换数据。一个线程调用 exchange() 方法,将数据传递给另一个线程,同时接收另一个线程的数据。

import java.util.concurrent.Exchanger;

public class Main {
    public static void main(String[] args) {
        Exchanger<String> exchanger = new Exchanger<>();

        Thread thread1 = new Thread(() -> {
            try {
                String message = "Message from thread1";
                String response = exchanger.exchange(message);
                System.out.println("Thread1 received: " + response);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                String message = "Message from thread2";
                String response = exchanger.exchange(message);
                System.out.println("Thread2 received: " + response);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        thread1.start();
        thread2.start();
    }
}

④、使用 CompletableFuture,CompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程。

public class Main {
    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            // 模拟长时间计算
            return "Message from CompletableFuture";
        });

        future.thenAccept(message -> {
            System.out.println("Received: " + message);
        });
    }
}

15.sleep 和 wait 的区别?

sleep() 和 wait() 是 Java 中用于暂停当前线程的两个重要方法,sleep 是让当前线程休眠,不涉及对象类,也不需要获取对象的锁,属于 Thread 类的方法;wait 是让获得对象锁的线程实现等待,前提要获得对象的锁,属于 Object 类的方法。

①、所属类不同

sleep() 方法专属于 Thread 类。 wait() 方法专属于 Object 类。

②、锁行为不同

当线程执行 sleep 方法时,它不会释放任何锁。也就是说,如果一个线程在持有某个对象的锁时调用了 sleep,它在睡眠期间仍然会持有这个锁。

class SleepDoesNotReleaseLock {

    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread sleepingThread = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread 1 会继续持有锁,并且进入睡眠状态");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 1 醒来了,并且释放了锁");
            }
        });

        Thread waitingThread = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread 2 进入同步代码块");
            }
        });

        sleepingThread.start();
        Thread.sleep(1000);
        waitingThread.start();
    }
}
Thread 1 会继续持有锁,并且进入睡眠状态
Thread 1 醒来了,并且释放了锁
Thread 2 进入同步代码块

从输出中我们可以看到,waitingThread 必须等待 sleepingThread 完成睡眠后才能进入同步代码块。

而当线程执行 wait 方法时,它会释放它持有的那个对象的锁,这使得其他线程可以有机会获取该对象的锁。

class WaitReleasesLock {

    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread waitingThread = new Thread(() -> {
            synchronized (lock) {
                try {
                    System.out.println("Thread 1 持有锁,准备等待 5 秒");
                    lock.wait(5000);
                    System.out.println("Thread 1 醒来了,并且退出同步代码块");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread notifyingThread = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread 2 尝试唤醒等待中的线程");
                lock.notify();
                System.out.println("Thread 2 执行完了 notify");
            }
        });

        waitingThread.start();
        Thread.sleep(1000);
        notifyingThread.start();
    }
}
Thread 1 持有锁,准备等待 5 秒
Thread 2 尝试唤醒等待中的线程
Thread 2 执行完了 notify
Thread 1 醒来了,并且退出同步代码块

这表明 waitingThread 在调用 wait 后确实释放了锁。

③、使用条件不同

sleep() 方法可以在任何地方被调用。

wait() 方法必须在同步代码块或同步方法中被调用,这是因为调用 wait() 方法的前提是当前线程必须持有对象的锁。否则会抛出 IllegalMonitorStateException 异常。

④、唤醒方式不同

sleep() 方法在指定的时间过后,线程会自动唤醒继续执行。

wait() 方法需要依靠 notify()、notifyAll() 方法或者 wait() 方法中指定的等待时间到期来唤醒线程。

⑤、抛出异常不同

sleep() 方法在等待期间,如果线程被中断,会抛出 InterruptedException。

如果线程被中断或等待时间到期时,wait() 方法同样会在等待期间抛出 InterruptedException。

sleep()的用法:

class SleepExample {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            System.out.println("线程准备休眠 2 秒");
            try {
                Thread.sleep(2000); // 线程将睡眠2秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程醒来了");
        });

        thread.start();
    }
}

wait()的用法:

class WaitExample {
    public static void main(String[] args) {
        final Object lock = new Object();

        Thread thread = new Thread(() -> {
            synchronized (lock) {
                try {
                    System.out.println("线程准备等待 2 秒");
                    lock.wait(2000); // 线程会等待2秒,或者直到其他线程调用 lock.notify()/notifyAll()
                    System.out.println("线程结束等待");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        thread.start();
    }
}

16.举例一个线程安全的使用场景?

线程安全是 Java 并发编程中一个非常重要的概念,它指的是多线程环境下,多个线程对共享资源的访问不会导致数据的不一致性。

一个常见的使用场景是在实现单例模式时确保线程安全。

单例模式确保一个类只有一个实例,并提供一个全局访问点。在多线程环境下,如果多个线程同时尝试创建实例,单例类必须确保只创建一个实例。

饿汉式是一种比较直接的实现方式,它通过在类加载时就立即初始化单例对象来保证线程安全。

public class EagerSingleton {
    private static final EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {}

    public static EagerSingleton getInstance() {
        return instance;
    }
}

这种方式简单高效,但由于实例在类加载时就已经创建,可能会浪费内存资源。

懒汉式是一种更常用的实现方式,它通过延迟初始化单例对象,在第一次使用时才创建实例。

public class LazySingleton {
    private volatile static LazySingleton instance;

    private LazySingleton() {}

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

双重检查锁定确保了线程安全,并且只在第一次创建实例时加锁,提高了效率。

17.请说一下 ThreadLocal 的作用和使用场景?

ThreadLocal是什么?

ThreadLocal 是 Java 中提供的一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离,用于解决多线程中共享对象的线程安全问题。

ThreadLocal 的工作原理

ThreadLocal 的核心思想是为每个线程提供一个独立的变量副本,这样每个线程都可以独立地修改自己的副本,而不会影响到其他线程的数据。ThreadLocal 类本身维护了一个映射表(ThreadLocalMap),其中键是 ThreadLocal 对象,值则是每个线程对应的变量副本。

ThreadLocal 的使用场景

线程上下文传递

在跨线程调用的场景中,可以使用 ThreadLocal 来存储和传递线程上下文信息。例如,可以将请求 ID、用户身份信息等存储在线程局部变量中,以便在后续的请求处理过程中方便地访问这些信息。

数据库连接管理

在使用数据库连接池的情况下,可以将数据库连接存储在 ThreadLocal 中,这样每个线程可以独立管理自己的数据库连接,避免了线程间的竞争和冲突。例如,MyBatis 中的 SqlSession 对象就使用 ThreadLocal 来存储当前线程的数据库会话信息。

事务管理

在需要手动管理事务的场景下,可以使用 ThreadLocal 来存储事务上下文信息,每个线程可以独立控制自己的事务,保证事务的隔离性。Spring 中的 TransactionSynchronizationManager 就使用 ThreadLocal 来存储事务相关的上下文信息。

工具类或辅助类

有时为了方便,可以将一些工具类或辅助类的实例存储在 ThreadLocal 中,这样在多线程环境中每个线程都有自己独立的实例,避免了线程间的干扰。

临时数据存储

在线程内部,如果需要存储一些临时数据,并且这些数据只在当前线程中有效,可以使用 ThreadLocal 来存储这些数据,避免了复杂的参数传递。

ThreadLocal 的使用步骤

①、创建 ThreadLocal

//创建一个ThreadLocal变量
public static ThreadLocal<String> localVariable = new ThreadLocal<>();

②、设置 ThreadLocal 的值

//设置ThreadLocal变量的值
localVariable.set("java");

③、获取 ThreadLocal 的值

//获取ThreadLocal变量的值
String value = localVariable.get();

④、删除 ThreadLocal 的值

//删除ThreadLocal变量的值
localVariable.remove();

18.除了 ThreadLocal,还有什么解决线程安全问题的方法?

①、Java 中的 synchronized 关键字可以用于方法和代码块,确保同一时间只有一个线程可以执行特定的代码段。

public synchronized void method() {
    // 线程安全的操作
}

②、Java 并发包(java.util.concurrent.locks)中提供了 Lock 接口和一些实现类,如 ReentrantLock。相比于 synchronized,ReentrantLock 提供了公平锁和非公平锁。

ReentrantLock lock = new ReentrantLock();

public void method() {
    lock.lock();
    try {
        // 线程安全的操作
    } finally {
        lock.unlock();
    }
}

③、Java 并发包还提供了一组原子变量类(如 AtomicInteger,AtomicLong 等),它们利用 CAS(比较并交换),实现了无锁的原子操作,适用于简单的计数器场景。

AtomicInteger atomicInteger = new AtomicInteger(0);

public void increment() {
    atomicInteger.incrementAndGet();
}

④、Java 并发包提供了一些线程安全的集合类,如 ConcurrentHashMap,CopyOnWriteArrayList 等。这些集合类内部实现了必要的同步策略,提供了更高效的并发访问。

ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

⑤、volatile 变量保证了变量的可见性,修改操作是立即同步到主存的,读操作从主存中读取。

private volatile boolean flag = false;

19.ThreadLocal 怎么实现的呢?

ThreadLocal 本身并不存储任何值,它只是作为一个映射,来映射线程的局部变量。当一个线程调用 ThreadLocal 的 set 或 get 方法时,实际上是访问线程自己的 ThreadLocal.ThreadLocalMap。

ThreadLocalMap 是 ThreadLocal 的静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量本身。

早期的 ThreadLocal 不是这样的,它的 ThreadLocalMap 中使用 Thread 作为 key,这也是最简单的实现方式。

优化后的方案有两个好处,一个是 Map 中存储的键值对变少了;另一个是 ThreadLocalMap 的生命周期和线程一样长,线程销毁的时候,ThreadLocalMap 也会被销毁。

Entry 继承了 WeakReference,它限定了 key 是一个弱引用,弱引用的好处是当内存不足时,JVM 会回收 ThreadLocal 对象,并且将其对应的 Entry 的 value 设置为 null,这样在很大程度上可以避免内存泄漏。

ThreadLocal 的实现原理就是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。

1、当需要存线程隔离的对象时,通过 ThreadLocal 的 set 方法将对象存入 Map 中。

2、当需要取线程隔离的对象时,通过 ThreadLocal 的 get 方法从 Map 中取出对象。

3、Map 的大小由 ThreadLocal 对象的多少决定。

20.java中的引用类型?

在 Java 中,引用类型有四种:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。每种引用类型都有其特定的用途和行为。下面详细介绍这四种引用类型及其应用场景。

强引用(Strong Reference)

特点:

最常用的引用类型。

只要有强引用指向一个对象,垃圾回收器不会回收该对象。

对象的生命周期最长。

public class StrongReferenceExample {
    public static void main(String[] args) {
        Object obj = new Object(); // 强引用
        obj = null; // 断开强引用
        // 如果没有其他强引用指向这个对象,垃圾回收器可以回收它
    }
}

应用场景:

适用于需要长期保持对象引用的场景,如全局变量、成员变量等。

软引用(Soft Reference)

特点:

软引用用于描述一些非必需但仍然有用的对象。

当系统即将发生内存溢出(OutOfMemoryError)时,会尝试回收软引用指向的对象。

软引用比弱引用更持久,只有在系统内存不足时才会被回收。

import java.lang.ref.SoftReference;

public class SoftReferenceExample {
    public static void main(String[] args) {
        Object obj = new Object();
        SoftReference<Object> softRef = new SoftReference<>(obj);
        obj = null; // 断开强引用
        
        // 如果没有其他强引用指向这个对象,垃圾回收器可以回收它
        System.gc(); // 请求垃圾回收
        
        // 检查对象是否已被回收
        if (softRef.get() == null) {
            System.out.println("对象已被垃圾回收");
        } else {
            System.out.println("对象还未被垃圾回收");
        }
    }
}

应用场景:

适用于实现缓存,特别是当缓存对象较大时,可以使用软引用来自动释放内存,避免 OutOfMemoryError。

例如,java.util.WeakHashMap 使用软引用作为键。

弱引用(Weak Reference)

特点:

弱引用用于描述那些非必需的对象。

当垃圾回收器运行时,无论系统内存是否充足,都会回收弱引用指向的对象。

弱引用比软引用更容易被回收。

import java.lang.ref.WeakReference;

public class WeakReferenceExample {
    public static void main(String[] args) {
        Object obj = new Object();
        WeakReference<Object> weakRef = new WeakReference<>(obj);
        obj = null; // 断开强引用
        
        // 如果没有其他强引用指向这个对象,垃圾回收器可以回收它
        System.gc(); // 请求垃圾回收
        
        // 检查对象是否已被回收
        if (weakRef.get() == null) {
            System.out.println("对象已被垃圾回收");
        } else {
            System.out.println("对象还未被垃圾回收");
        }
    }
}

应用场景:

适用于实现缓存,特别是当缓存对象较小且不需要长期保存时。

例如,java.lang.ref.WeakHashMap 使用弱引用作为键。

虚引用(Phantom Reference)

特点:

虚引用是最弱的一种引用关系。

虚引用并不会决定对象的生命周期。

虚引用主要用于跟踪对象的垃圾回收状态。

虚引用必须与引用队列(ReferenceQueue)关联使用。

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceExample {
    public static void main(String[] args) {
        Object obj = new Object();
        ReferenceQueue<Object> queue = new ReferenceQueue<>();
        PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
        obj = null; // 断开强引用
        
        // 如果没有其他强引用指向这个对象,垃圾回收器可以回收它
        System.gc(); // 请求垃圾回收
        
        // 检查对象是否已被回收
        if (queue.poll() != null) {
            System.out.println("对象已被垃圾回收");
        } else {
            System.out.println("对象还未被垃圾回收");
        }
    }
}

应用场景:

适用于跟踪对象的垃圾回收状态,通常用于实现对象的最终化处理。

例如,可以用来实现对象的清理逻辑,确保对象被垃圾回收后执行某些清理操作。

21.ThreadLocal内存泄漏是怎么回事?

通常情况下,随着线程 Thread 的结束,其内部的 ThreadLocalMap 也会被回收,从而避免了内存泄漏。

但如果一个线程一直在运行,并且其 ThreadLocalMap 中的 Entry.value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。当 Entry 非常多时,可能就会引发更严重的内存溢出问题。

如何解决内存泄漏问题?

使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间。

try {
    threadLocal.set(value);
    // 执行业务操作
} finally {
    threadLocal.remove(); // 确保能够执行清理
}

remove() 方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。

private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i];
            e != null;
            e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

public void clear() {
    this.referent = null;
}

22.ThreadLocal结合线程池使用导致的复用问题?

当 ThreadLocal 与线程池结合使用时,特别容易出现复用问题,主要原因如下:

线程复用:线程池的一个主要特性就是它可以重用已经创建好的线程来执行新任务。这意味着同一个线程可能会被执行多个不同的任务。

数据残留:如果前一个任务在使用 ThreadLocal 变量后没有清理,那么当线程被复用执行下一个任务时,可能会读取到前一个任务留下的 ThreadLocal 变量的值。这对于需要独立上下文的新任务来说是不正确的,可能导致数据混乱或错误的行为。

避免复用问题:为了避免出现复用问题,可以在使用 ThreadLocal 后调用 ThreadLocal 的 remove() 方法来清除 ThreadLocal 变量的值。这样,当线程被复用时,ThreadLocal 变量中的值会被重置,确保每个任务都有自己的独立上下文。

23.ThreadLocal的删除过程?

当一个 ThreadLocal 对象不再被任何强引用持有时,它的生命周期就结束了。此时,ThreadLocalMap 中对应条目的键变成了 null。当 ThreadLocal 的 get() 或 set() 方法被调用时,ThreadLocalMap 会清理掉所有键为 null 的条目,这些条目即为那些已经没有强引用的 ThreadLocal 对象。

此外,如果希望在 ThreadLocal 对象还存在时就清除某个线程上的绑定值,可以调用 ThreadLocal 的 remove() 方法。这会从当前线程的 ThreadLocalMap 中移除该 ThreadLocal 对象的条目。

24.ThreadLocalMap的源码分析?

元素数组

一个 table 数组,存储 Entry 类型的元素,Entry 是 ThreaLocal 弱引用作为 key,Object 作为 value 的结构。

 private Entry[] table;

散列方法

散列方法就是怎么把对应的 key 映射到 table 数组的相应下标,ThreadLocalMap 用的是哈希取余法,取出 key 的 threadLocalHashCode,然后和 table 数组长度减一&运算(相当于取余)。

int i = key.threadLocalHashCode & (table.length - 1);

这里的 threadLocalHashCode 计算有点东西,每创建一个 ThreadLocal 对象,它就会新增0x61c88647,这个值很特殊,它是斐波那契数 也叫 黄金分割数。hash增量为 这个数字,带来的好处就是 hash 分布非常均匀。

    private static final int HASH_INCREMENT = 0x61c88647;

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

25.ThreadLocalMap如何解决Hash冲突?

使用开放地址法解决哈希冲突。若果某个位置被占用,则继续在当前位置的下一个位置查找,直到找到空位置为止。

26.ThreadLocalMap的扩容?

扩容条件

当向 ThreadLocalMap 添加一个新的 ThreadLocal 变量时,如果当前 ThreadLocalMap 的大小达到了某个阈值(通常是当前容量的三分之二),那么就会触发扩容操作。

扩容过程

扩容操作首先会检查当前的 ThreadLocalMap 是否为空,以及是否大于当前容量的三分之二。如果满足条件,则会进行扩容。 扩容的具体操作是创建一个新的数组,其大小通常是原数组大小的两倍。 接着,旧数组中的所有元素会被重新散列,并放入新的数组中。 在此过程中,如果发现某些 ThreadLocal 对象已经被垃圾回收(即它们的引用变为 null),那么这些条目会被清理掉。

重新散列

重新散列的过程涉及到计算每个 ThreadLocal 对象的哈希值,并确定其在新数组中的位置。 这个过程确保了即使在扩容后,ThreadLocal 对象也能正确地映射到 ThreadLocalMap 中的位置。

更新引用

完成重新散列后,ThreadLocalMap 内部的指针会被更新以指向新的数组。

垃圾回收

在扩容过程中,还会检查是否有已经被垃圾回收的 ThreadLocal 对象,并进行相应的清理工作。这是为了防止内存泄漏的发生。

27.父子线程怎么共享数据?

使用 InheritableThreadLocal

InheritableThreadLocal 是 ThreadLocal 的一个子类,它允许子线程继承父线程中的 ThreadLocal 变量。当创建子线程时,如果父线程中有 InheritableThreadLocal 变量,那么这些变量会被复制到子线程中。

// 创建一个可继承的线程局部变量
InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
inheritableThreadLocal.set("Parent Value");

// 创建一个子线程
Thread childThread = new Thread(() -> {
    System.out.println("Child Thread Value: " + inheritableThreadLocal.get());
});

childThread.start();

在这个例子中,子线程启动时能够访问到父线程中设置的 InheritableThreadLocal 变量的值。

28.为什么线程要使用自己的内存?

第一,在多线程环境中,如果所有线程都直接操作主内存中的共享变量,会引发更多的内存访问竞争,这不仅影响性能,还增加了线程安全问题的复杂度。通过让每个线程使用本地内存,可以减少对主内存的直接访问和竞争,从而提高程序的并发性能。

第二,现代 CPU 为了优化执行效率,可能会对指令进行乱序执行(指令重排序)。使用本地内存(CPU 缓存和寄存器)可以在不影响最终执行结果的前提下,使得 CPU 有更大的自由度来乱序执行指令,从而提高执行效率。

29.对原子性、可见性、有序性的理解?

原子性:原子性指的是一个操作是不可分割、不可中断的,要么全部执行并且执行的过程不会被任何因素打断,要么就全不执行。

可见性:可见性指的是一个线程修改了共享变量的值,其他线程能够立即看到这个修改。

有序性:有序性指的是对于一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的,但是并发时有可能会发生指令重排。

如何确保原子性、可见性、有序性?

原子性:JMM 只能保证基本的原子性,如果要保证一个代码块的原子性,需要使用synchronized 。

可见性:Java 是利用volatile关键字来保证可见性的,除此之外,final和synchronized也能保证可见性。

有序性:synchronized或者volatile都可以保证多线程之间操作的有序性。

30.什么是指令重排?

指令重排是指编译器或处理器为了优化性能而改变程序中指令执行顺序的行为。虽然重排后的指令在逻辑上仍然遵循程序的语义,但在实际执行时可能会导致一些意想不到的问题,特别是在多线程环境中。

指令重排的原因

指令重排主要有以下几种原因:

编译器优化

提前计算:编译器可能会提前计算一些表达式的值,以减少运行时的计算开销。

延迟加载:编译器可能会延迟加载一些变量的值,直到真正需要使用时才加载,以减少不必要的内存访问。

处理器优化

乱序执行:现代处理器采用乱序执行技术,可以在不影响最终结果的情况下调整指令的执行顺序,以充分利用硬件资源。

流水线执行:处理器的流水线技术可以将指令分解成多个阶段并行执行,从而提高执行效率。

内存访问优化

预取:处理器可能会预先加载一些数据到缓存中,以减少未来的内存访问延迟。

写后置:处理器可能会将写操作推迟到适当的时机,以减少内存访问次数。

指令重排的类型

指令重排可以分为以下几种类型:

编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。

内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

指令重排对多线程程序的影响

指令重排在多线程环境中可能会导致以下问题:

数据不一致

如果两个线程同时访问和修改同一个变量,指令重排可能会导致数据不一致的问题。

例如,线程 A 先写入变量 x,然后写入变量 y;线程 B 可能会先读取到变量 y 的新值,然后再读取到变量 x 的旧值,导致数据不一致。

内存可见性问题

指令重排可能会导致内存可见性问题,即一个线程修改的变量值在另一个线程中不可见。

例如,线程 A 修改了变量 x,但由于指令重排,线程 B 可能会读取到变量 x 的旧值。

死锁和活锁

指令重排可能会导致死锁和活锁问题,特别是在使用锁和其他同步机制时。

例如,线程 A 和线程 B 同时尝试获取两个锁,但由于指令重排,可能会导致死锁。

避免指令重排的方法

使用 volatile 关键字

volatile 变量的读写操作会插入内存屏障,禁止编译器和处理器对相关指令进行重排序。

volatile int x = 0;

public void writeX() {
    x = 1;
}

public void readX() {
    if (x == 1) {
        // do something
    }
}

使用 synchronized 关键字

synchronized 修饰的方法或代码块会插入内存屏障,禁止编译器和处理器对相关指令进行重排序。

public synchronized void increment() {
    count++;
}

使用 final 关键字

final 变量一旦初始化就不能再改变,确保了线程之间的可见性和有序性。

final int x = 0;

指令重排的限制

两个规则happens-before和as-if-serial来约束。

happens-before规则

如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法

程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。

监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。

volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

start()规则:如果线程 A 执行操作 ThreadB.start()(启动线程 B),那么 A 线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作。

join()规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。

as-if-serial规则

不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。

double pi = 3.14;   // A
double r = 1.0;   // B
double area = pi * r * r;   // C

A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。

31.volatile关键字的实现原理?

可见性

volatile 变量的主要作用之一是确保变量的可见性。具体来说:

写操作:当一个线程修改了一个 volatile 变量的值时,这个新的值会被立即写回到主内存中。

读操作:当一个线程读取一个 volatile 变量的值时,它会从主内存中读取最新的值,而不是从本地缓存中读取。

有序性

volatile 变量还确保了操作的有序性。具体来说:

内存屏障:volatile 变量的读写操作会插入内存屏障(memory fence),禁止相关的指令重排。

写屏障:在 volatile 变量的写操作前后插入写屏障,确保写操作的有序性。

读屏障:在 volatile 变量的读操作前后插入读屏障,确保读操作的有序性。

写操作

当一个线程修改了一个 volatile 变量的值时:

写操作前插入写屏障:确保之前的写操作已经完成。

写入主内存:将新的值写入主内存。

写操作后插入写屏障:确保后续的写操作不会重排到这个写操作之前。

读操作

当一个线程读取一个 volatile 变量的值时:

读操作前插入读屏障:确保之前的读操作已经完成。

从主内存读取:从主内存中读取最新的值。

读操作后插入读屏障:确保后续的读操作不会重排到这个读操作之前。

32.volatile加在基本类型和对象上的区别?

在 Java 中,volatile 关键字可以用于修饰基本类型和对象。

当 volatile 用于基本数据类型时,能确保该变量的读写操作是直接从主内存中读取或写入的。

当 volatile 用于引用类型时,它确保引用本身的可见性,即确保引用指向的对象地址是最新的。

但是,volatile 并不能保证引用对象内部状态的线程安全性。

33.synchronized 用过吗?怎么使用?

synchronized 是 Java 中用于实现线程同步的关键字,它可以确保共享资源被多个线程安全访问,防止数据不一致的情况发生。synchronized 主要有以下三种用法:

修饰实例方法: 当一个方法被 synchronized 修饰时,该方法被称为同步方法。当一个线程访问某个对象的同步方法时,它首先必须获得该对象的锁,其他试图访问该对象其他同步方法的线程将会阻塞,直到第一个线程执行完毕并释放对象锁。

public class MyClass {
    public synchronized void myMethod() {
        // 方法体
    }
}

修饰静态方法: 当 synchronized 修饰静态方法时,它锁定的是类的 Class 对象,而不是实例对象。这意味着对于所有实例来说,静态同步方法在同一时刻只能被一个线程访问。

public class MyClass {
    public static synchronized void myStaticMethod() {
        // 方法体
    }
}

修饰代码块: synchronized 还可以用来修饰代码块,允许开发者指定一个对象作为锁对象,这样只有获得了该对象锁的线程才能执行这段代码块。

public class MyClass {
    private final Object myLock = new Object();
    
    public void myMethod() {
        synchronized (myLock) {
            // 同步代码块
        }
    }
}

synchronized 的实现原理?

synchronized 在 Java 中是一个关键字,用于实现线程间的同步。其底层实现原理依赖于 JVM(Java 虚拟机)提供的 Monitor(监视器)机制,并且在不同的 Java 版本中有所优化。以下是 synchronized 的实现原理概览:

Monitor 监视器

每个对象都有一个与之关联的监视器锁,也称为 Monitor。当一个线程进入一个 synchronized 代码块或方法时,它必须先获取对象的 Monitor。

如果该 Monitor 已经被另一个线程持有,请求锁的线程将被阻塞,直到当前持有锁的线程释放锁。

当线程退出 synchronized 块或方法时,它会释放该对象的 Monitor,使得其他线程有机会获取锁。

可重入锁

synchronized 支持可重入性,这意味着同一个线程可以多次获取同一个对象的锁而不会导致死锁。每次进入 synchronized 区域都会增加锁的计数,相应地,每次退出都会减少计数,直到计数归零才真正释放锁。

synchronized 之所以支持可重入,是因为 Java 的对象头包含了一个 Mark Word,用于存储对象的状态,包括锁信息。

当一个线程获取对象锁时,JVM 会将该线程的 ID 写入 Mark Word,并将锁计数器设为 1。

如果一个线程尝试再次获取已经持有的锁,JVM 会检查 Mark Word 中的线程 ID。如果 ID 匹配,表示的是同一个线程,锁计数器递增。

当线程退出同步块时,锁计数器递减。如果计数器值为零,JVM 将锁标记为未持有状态,并清除线程 ID 信息。

锁的优化

从 Java 1.6 开始,为了减少锁的开销,引入了多种锁的状态,包括偏向锁(Biased Locking)、轻量级锁(Lightweight Locking)和重量级锁(Heavyweight Locking)。

偏向锁:当一个线程访问同步代码前,会先检查是否有线程已经获取了锁,如果没有,虚拟机会尝试将对象头的所有者设置为当前线程,同时将状态改为偏向模式。这样后续该线程再次访问时就不需要额外的同步操作。

轻量级锁:当有第二个线程尝试获取锁时,会使用 CAS 操作来尝试获取锁。如果 CAS 失败,则会尝试自旋(Spinning),即循环尝试获取锁。

重量级锁:如果自旋一定次数仍然没有获取到锁,则会放弃自旋,线程进入阻塞状态,等待锁的持有线程释放锁后再通过操作系统内核调度唤醒。

锁的升级

①、从无锁到偏向锁

当一个线程首次访问同步块时,如果此对象无锁状态且偏向锁未被禁用,JVM 会将该对象头的锁标记改为偏向锁状态,并记录下当前线程的 ID。此时,对象头中的 Mark Word 中存储了持有偏向锁的线程 ID。

如果另一个线程尝试获取这个已被偏向的锁,JVM 会检查当前持有偏向锁的线程是否活跃。如果持有偏向锁的线程不活跃,则可以将锁重偏向至新的线程;如果持有偏向锁的线程还活跃,则需要撤销偏向锁,升级为轻量级锁。

②、偏向锁的轻量级锁

进行偏向锁撤销时,会遍历堆栈的所有锁记录,暂停拥有偏向锁的线程,并检查锁对象。如果这个过程中发现有其他线程试图获取这个锁,JVM 会撤销偏向锁,并将锁升级为轻量级锁。

当有两个或以上线程竞争同一个偏向锁时,偏向锁模式不再有效,此时偏向锁会被撤销,对象的锁状态会升级为轻量级锁。

③、轻量级锁到重量级锁

轻量级锁通过线程自旋来等待锁释放。如果自旋超过预定次数(自旋次数是可调的,并且自适应的),表明锁竞争激烈,轻量级锁的自旋已经不再高效。

当自旋等待失败,或者有线程在等待队列中等待相同的轻量级锁时,轻量级锁会升级为重量级锁。在这种情况下,JVM 会在操作系统层面创建一个互斥锁(Mutex),所有进一步尝试获取该锁的线程将会被阻塞,直到锁被释放。

锁的获取与释放

synchronized 的实现依赖于 JVM 的内部指令 monitorenter 和 monitorexit。当线程执行到 synchronized 代码块之前,会执行 monitorenter 指令获取锁;当执行完 synchronized 代码块之后,会执行 monitorexit 指令释放锁。

34.synchronized和ReentrantLock区别和场景?

synchronized 和 ReentrantLock 都是 Java 中用于实现线程同步的重要工具,但它们之间存在一些关键的区别,这些区别决定了它们在不同场景下的适用性。以下是两者的主要区别及适用场景:

使用方式

synchronized 是 Java 关键字,直接在代码级别声明同步区域或方法。

ReentrantLock 是一个类,实现了 Lock 接口,需要显式地调用 lock() 方法获取锁,以及 unlock() 方法释放锁。

// synchronized 修饰方法
public synchronized void method() {
    // 业务代码
}

// synchronized 修饰代码块
synchronized (this) {
    // 业务代码
}

// ReentrantLock 加锁
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 业务代码
} finally {
    lock.unlock();
}

锁的释放

synchronized 在线程抛出异常时能够自动释放锁,因此不需要担心由于异常而导致的死锁。

ReentrantLock 必须显式地调用 unlock() 方法释放锁,如果在 try 块中获取锁而在 finally 块中释放锁,则可以保证即使抛出异常也能释放锁,否则可能导致死锁。

响应中断

synchronized 不支持响应中断,如果一个线程在等待锁时被中断,它仍然会等待锁。

ReentrantLock 支持响应中断,可以通过 lockInterruptibly() 方法来允许等待锁的线程响应中断。

锁的公平性

synchronized 总是非公平锁,即新来的线程可能会优先于已经在等待的线程获取锁。

ReentrantLock 可以选择是公平锁还是非公平锁,通过构造函数传入 true 或 false 参数来决定。

扩展性

synchronized 提供的功能较为简单,主要用于基本的同步需求。

ReentrantLock 提供了更丰富的功能,如尝试锁(tryLock)、可中断锁(lockInterruptibly)、定时锁(tryLock(long time, TimeUnit unit))等高级功能。

适用场景:

synchronized 更适合于简单的同步需求,尤其是在代码简洁性和安全性更为重要的情况下。它的使用更加简单,不需要额外的代码来管理锁的获取和释放。

ReentrantLock 适用于需要更精细控制锁行为的场景,比如需要支持中断或者希望实现公平锁,以及需要在等待锁时进行超时处理等。它提供了更多的灵活性和控制力

性能考量

在性能方面,早期 synchronized 的性能较差,但在 Java 6 之后,随着 JVM 对 synchronized 的优化(如引入偏向锁、轻量级锁等),在某些低竞争场景下,synchronized 的性能可能优于 ReentrantLock。然而,在高竞争场景下,ReentrantLock 可能表现出更好的性能,因为它的实现可以更好地利用现代多核处理器的优势。

并发量大的情况下,使用 synchronized 还是 ReentrantLock

在并发量特别高的情况下,ReentrantLock 的性能可能会优于 synchronized,原因包括:

ReentrantLock 提供了超时和公平锁等特性,可以更好地应对复杂的并发场景 。

ReentrantLock 允许更细粒度的锁控制,可以有效减少锁竞争。

ReentrantLock 支持条件变量 Condition,可以实现比 synchronized 更复杂的线程间通信机制。

35.AQS 了解多少?

AQS,全称是 AbstractQueuedSynchronizer,中文意思是抽象队列同步器,由 Doug Lea 设计,是 Java 并发包java.util.concurrent的核心框架类,许多同步类的实现都依赖于它,如 ReentrantLock、Semaphore、CountDownLatch 等。

AQS 的思想是,如果被请求的共享资源空闲,则当前线程能够成功获取资源;否则,它将进入一个等待队列,当有其他线程释放资源时,系统会挑选等待队列中的一个线程,赋予其资源。

整个过程通过维护一个 int 类型的状态和一个先进先出(FIFO)的队列,来实现对共享资源的管理。

①、同步状态 state 由 volatile 修饰,保证了多线程之间的可见性;

private volatile int state;

②、同步队列是通过内部定义的 Node 类来实现的,每个 Node 包含了等待状态、前后节点、线程的引用等。

static final class Node {
    static final int CANCELLED =  1;
    static final int SIGNAL    = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;

    volatile Node prev;

    volatile Node next;

    volatile Thread thread;
}

AQS 支持两种同步方式:

独占模式:这种方式下,每次只能有一个线程持有锁,例如 ReentrantLock。

共享模式:这种方式下,多个线程可以同时获取锁,例如 Semaphore 和 CountDownLatch。

子类可以通过继承 AQS 并实现它的方法来管理同步状态,这些方法包括:

tryAcquire:独占方式尝试获取资源,成功则返回 true,失败则返回 false;

tryRelease:独占方式尝试释放资源;

tryAcquireShared(int arg):共享方式尝试获取资源;

tryReleaseShared(int arg):共享方式尝试释放资源;

isHeldExclusively():该线程是否正在独占资源。

如果共享资源被占用,需要一种特定的阻塞等待唤醒机制来保证锁的分配,AQS 会将竞争共享资源失败的线程添加到一个 CLH 队列中。

在 CLH 锁中,当一个线程尝试获取锁并失败时,它会将自己添加到队列的尾部并自旋,等待前一个节点的线程释放锁。

36.ReentrantLock 实现原理?

ReentrantLock 是可重入的独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞。

可重入表示当前线程获取该锁后再次获取不会被阻塞,也就意味着同一个线程可以多次获得同一个锁而不会发生死锁。

new ReentrantLock() 默认创建的是非公平锁 NonfairSync。

公平锁 FairSync

在公平锁模式下,锁会授予等待时间最长的线程。

非公平锁 NonfairSync

在非公平锁模式下,锁可能会授予刚刚请求它的线程,而不考虑等待时间。

ReentrantLock 内部通过一个计数器来跟踪锁的持有次数。

当线程调用lock()方法获取锁时,ReentrantLock 会检查当前状态,判断锁是否已经被其他线程持有。如果没有被持有,则当前线程将获得锁;如果锁已被其他线程持有,则当前线程将根据锁的公平性策略,可能会被加入到等待队列中。

线程首次获取锁时,计数器值变为 1;如果同一线程再次获取锁,计数器增加;每释放一次锁,计数器减 1。

当线程调用unlock()方法时,ReentrantLock 会将持有锁的计数减 1,如果计数到达 0,则释放锁,并唤醒等待队列中的线程来竞争锁。

37.ReentrantLock 怎么实现公平锁的?

在 ReentrantLock 中,公平锁的实现主要通过 FairSync 类来完成,它是 ReentrantLock.Sync 的子类,继承自 AbstractQueuedSynchronizer(AQS)。公平锁的核心在于确保线程获取锁的顺序符合它们在队列中的位置。

公平锁意味着在多个线程竞争锁时,获取锁的顺序与线程请求锁的顺序相同,即先来先服务(FIFO)。

虽然能保证锁的顺序,但实现起来比较复杂,因为需要额外维护一个有序队列。

非公平锁不保证线程获取锁的顺序,当锁被释放时,任何请求锁的线程都有机会获取锁,而不是按照请求的顺序。

38.CAS 了解多少?

CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作。

在 Java 中,我们可以使用 synchronized关键字和 CAS 来实现加锁效果。

synchronized 是悲观锁,尽管随着 JDK 版本的升级,synchronized 关键字已经“轻量级”了很多,但依然是悲观锁,线程开始执行第一步就要获取锁,一旦获得锁,其他的线程进入后就会阻塞并等待锁。

CAS 是乐观锁,线程执行的时候不会加锁,它会假设此时没有冲突,然后完成某项操作;如果因为冲突失败了就重试,直到成功为止。

在 CAS 中,有这样三个值:

V:要更新的变量(var)

E:预期值(expected)

N:新值(new)

比较并交换的过程如下

判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。

这里的预期值 E 本质上指的是“旧值”。

这个比较和替换的操作是原子的,即不可中断,确保了数据的一致性。

举个例子,变量当前的值为 0,需要将其更新为 1,可以借助 AtomicInteger 类的 compareAndSet 方法来实现。

AtomicInteger atomicInteger = new AtomicInteger(0);
int expect = 0;
int update = 1;
atomicInteger.compareAndSet(expect, update);

compareAndSet 就是一个 CAS 方法,它调用的是 Unsafe 的 compareAndSwapInt。

Unsafe 对 CAS 的实现是通过 C++ 实现的,它的具体实现和操作系统、CPU 都有关系。

Linux 的 X86 下主要是通过 cmpxchgl 这个指令在 CPU 上完成 CAS 操作的,但在多处理器情况下,必须使用 lock 指令加锁来完成。当然,不同的操作系统和处理器在实现方式上肯定会有所不同。

39.CAS 有什么问题?如何解决?

ABA 问题

描述:即使 CAS 操作成功,也不能保证在这段时间内没有发生过其他变化。例如,一个值从 A 变成 B 再变回 A,此时 CAS 操作仍然会成功,但实际上已经发生了改变。

解决方案

使用带有版本号或时间戳的原子引用类型(如 AtomicStampedReference),每次更新时都带上版本号,从而避免 ABA 问题。

public class OptimisticLockExample {
    private int version;
    private int value;

    public synchronized boolean updateValue(int newValue, int currentVersion) {
        if (this.version == currentVersion) {
            this.value = newValue;
            this.version++;
            return true;
        }
        return false;
    }
}

忙等待问题

描述:当 CAS 操作失败时,通常会进行自旋重试,这可能导致 CPU 资源浪费。

解决方案

引入延时重试机制,增加重试间隔时间。

结合其他同步机制,如锁,来避免长时间的忙等待。

在 Java 中,很多使用自旋 CAS 的地方,会有一个自旋次数的限制,超过一定次数,就停止自旋。

只能保证单个变量的原子性

描述:CAS 操作只能保证单个变量更新的原子性,对于多个变量同时更新无法保证原子性。

解决方案

对于多个变量的操作,可以考虑使用锁或其他更复杂的同步机制。

将多个变量封装成一个对象,然后对整个对象进行 CAS 操作。

40.原子操作类了解多少?

Atomic 包里的类基本都是使用 Unsafe 实现的包装类。

使用原子的方式更新基本类型,Atomic 包提供了以下 3 个类:

AtomicBoolean:原子更新布尔类型。

AtomicInteger:原子更新整型。

AtomicLong:原子更新长整型。

通过原子的方式更新数组里的某个元素,Atomic 包提供了以下 4 个类:

AtomicIntegerArray:原子更新整型数组里的元素。

AtomicLongArray:原子更新长整型数组里的元素。

AtomicReferenceArray:原子更新引用类型数组里的元素。

AtomicIntegerArray 类主要是提供原子的方式更新数组里的整型

原子更新基本类型的 AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic 包提供了以下 3 个类:

AtomicReference:原子更新引用类型。

AtomicReferenceFieldUpdater:原子更新引用类型里的字段。

AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是 AtomicMarkableReference(V initialRef,boolean initialMark)。

如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic 包提供了以下 3 个类进行原子字段更新:

AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。

AtomicLongFieldUpdater:原子更新长整型字段的更新器。

AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

AtomicInteger 的添加方法为例:

    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

通过Unsafe类的实例来进行添加操作,来看看具体的 CAS 操作:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

compareAndSwapInt 是一个 native 方法,基于 CAS 来操作 int 类型变量。其它的原子操作类基本都是大同小异。

41.线程死锁了解吗?该如何避免?

线程死锁是指两个或多个线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象。在这种情况下,每个线程都在等待另一个线程释放它所需要的资源,结果导致所有相关线程都无法继续执行下去。

死锁的必要条件

互斥条件:至少有一个资源必须处于非共享模式,即一次只能有一个线程使用。如果另一个线程想要使用该资源,那么它必须等待,直到拥有该资源的线程释放它。

请求与保持条件:一个线程已经持有了至少一个资源,但又提出了新的资源请求,而该资源已被其它线程占有,因此请求线程被阻塞。

不可抢占条件:线程已经获得的资源,在结束前不能被其他线程强行抢占,只能主动释放。

循环等待条件:存在一种涉及两个或多个线程的循环等待链,每个线程都在等待下一个线程持有的资源。

避免死锁的方法

打破循环等待条件

给资源分配一个全局唯一的顺序,所有线程按照相同的顺序请求资源。例如,如果系统中有多个锁,则按照锁对象的地址排序,线程总是按照从小到大的顺序获取锁。

使用超时机制,当线程请求资源时设置一个超时时间,超时后放弃资源请求。

打破不可抢占条件

允许持有较少资源的线程释放资源,让持有更多资源的线程先完成任务。

打破请求与保持条件

要求线程一次性请求所有需要的资源,而不是在持有部分资源的情况下再请求其他资源。

打破互斥条件

虽然完全避免互斥条件不太现实,但是可以通过减少互斥资源的数量或使用替代方案来降低死锁的可能性。

42.死锁问题怎么排查呢?

使用 jstack 命令

jstack 是一个 Java 工具,它可以打印出 Java 进程中的所有线程的堆栈跟踪信息。

命令示例:

jstack <pid>

在输出的信息中,你可以看到每个线程的状态,包括 BLOCKED 状态,这可能是死锁的迹象。

使用 jconsole 工具

jconsole 是一个图形化的监控工具,可以显示 JVM 的详细信息,包括线程状态。

使用方法:通过 jconsole 连接到目标 JVM,然后查看线程监视器图表,可以发现死锁的线程。

使用 VisualVM

VisualVM 是一个集成的工具,它提供了类似 jconsole 的功能,但还包含了更多的分析工具。

使用方法:打开 VisualVM,连接到目标 JVM,然后查看线程视图,查找死锁情况。

43.乐观锁和悲观锁?

对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

悲观锁的代表有 synchronized 关键字和 Lock 接口。

乐观锁,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行的安全性。

由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁。

乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;

悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。

44.CountDownLatch(倒计数器)了解吗?

CountDownLatch 是 Java 并发包(java.util.concurrent)中的一个同步辅助类,用于协调多个线程之间的同步。它允许一个或多个线程等待,直到在其他线程中执行的一组操作完成。

工作原理

CountDownLatch 通过一个计数器来实现,该计数器初始化为一个正整数。每当一个线程完成了它的工作后,计数器就会减一。当计数器的值变为零时,所有等待的线程都会被唤醒,继续执行。

主要方法

CountDownLatch(int count):构造一个用给定计数初始化的 CountDownLatch。

void await():使当前线程等待,直到计数器的值变为零,除非线程被中断。

boolean await(long timeout, TimeUnit unit):使当前线程等待,直到计数器的值变为零,或者等待超时,或者线程被中断。

void countDown():递减计数器的值。如果计数器的值变为零,则所有等待的线程都会被唤醒。

示例代码:

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) {
        int threadCount = 3;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            new Thread(new Worker(latch)).start();
        }

        try {
            // 主线程等待,直到计数器变为零
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("所有工作线程已完成,主线程继续执行。");
    }
}

class Worker implements Runnable {
    private final CountDownLatch latch;

    public Worker(CountDownLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
        try {
            // 模拟工作
            Thread.sleep((long) (Math.random() * 1000));
            System.out.println(Thread.currentThread().getName() + " 完成工作");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 递减计数器
            latch.countDown();
        }
    }
}

在这个示例中,主线程创建了三个工作线程,并使用 CountDownLatch 来等待所有工作线程完成工作。每个工作线程在完成工作后都会调用 countDown() 方法递减计数器。当计数器的值变为零时,主线程会被唤醒,继续执行后续操作。

45.CyclicBarrier(同步屏障)了解吗?

CyclicBarrier 是 Java 并发包(java.util.concurrent)中的一个同步辅助类,用于协调多个线程在某个点上相互等待,直到所有线程都到达这个点后再继续执行。它类似于 CountDownLatch,但 CyclicBarrier 可以被重用

工作原理

CyclicBarrier 通过一个计数器来实现,该计数器初始化为一个正整数,表示需要等待的线程数。每当一个线程到达屏障点时,计数器减一。当计数器的值变为零时,所有等待的线程都会被唤醒,继续执行。如果需要,CyclicBarrier 可以在所有线程被唤醒之前执行一个可选的屏障操作。

主要方法

CyclicBarrier(int parties):构造一个新的 CyclicBarrier,它将在指定数量的线程(parties)都调用 await 方法时触发。

CyclicBarrier(int parties, Runnable barrierAction):构造一个新的 CyclicBarrier,它将在指定数量的线程(parties)都调用 await 方法时触发,并在所有线程被唤醒之前执行给定的屏障操作。

int await():使当前线程在屏障点等待,直到所有线程都到达屏障点。

int await(long timeout, TimeUnit unit):使当前线程在屏障点等待,直到所有线程都到达屏障点,或者等待超时,或者线程被中断。

示例代码

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    public static void main(String[] args) {
        int threadCount = 3;
        CyclicBarrier barrier = new CyclicBarrier(threadCount, new Runnable() {
            @Override
            public void run() {
                System.out.println("所有线程都到达屏障点,继续执行...");
            }
        });

        for (int i = 0; i < threadCount; i++) {
            new Thread(new Worker(barrier)).start();
        }
    }
}

class Worker implements Runnable {
    private final CyclicBarrier barrier;

    public Worker(CyclicBarrier barrier) {
        this.barrier = barrier;
    }

    @Override
    public void run() {
        try {
            // 模拟工作
            Thread.sleep((long) (Math.random() * 1000));
            System.out.println(Thread.currentThread().getName() + " 到达屏障点");
            barrier.await();
            System.out.println(Thread.currentThread().getName() + " 继续执行");
        } catch (InterruptedException | BrokenBarrierException e) {
            e.printStackTrace();
        }
    }
}

在这个示例中,主线程创建了三个工作线程,并使用 CyclicBarrier 来让所有线程在某个点上相互等待。每个工作线程在到达屏障点后都会调用 await() 方法。当所有线程都到达屏障点时,屏障操作会被执行,然后所有线程继续执行后续操作。

46.CyclicBarrier 和 CountDownLatch 有什么区别?

两者最核心的区别:

CountDownLatch 是一次性的,而 CyclicBarrier 则可以多次设置屏障,实现重复利用;

CountDownLatch 中的各个子线程不可以等待其他线程,只能完成自己的任务;而 CyclicBarrier 中的各个线程可以等待其他线程

CyclicBarrier CountDownLatch
CyclicBarrier 是可重用的,其中的线程会等待所有的线程完成任务。届时,屏障将被拆除,并可以选择性地做一些特定的动作。 CountDownLatch 是一次性的,不同的线程在同一个计数器上工作,直到计数器为 0.
CyclicBarrier 面向的是线程数 CountDownLatch 面向的是任务数
在使用 CyclicBarrier 时,你必须在构造中指定参与协作的线程数,这些线程必须调用 await()方法 使用 CountDownLatch 时,则必须要指定任务数,至于这些任务由哪些线程完成无关紧要
CyclicBarrier 可以在所有的线程释放后重新使用 CountDownLatch 在计数器为 0 时不能再使用
在 CyclicBarrier 中,如果某个线程遇到了中断、超时等问题时,则处于 await 的线程都会出现问题 在 CountDownLatch 中,如果某个线程出现问题,其他线程不受影响

47.Semaphore(信号量)了解吗?

Semaphore 是 Java 并发包(java.util.concurrent)中的一个同步辅助类,用于控制对某个资源的访问数量。它可以用来限制同时访问某些资源的线程数量。

工作原理

Semaphore 维护了一个许可计数器,表示可以同时访问资源的最大线程数。线程在访问资源前需要获取许可,访问结束后需要释放许可。如果没有可用的许可,线程将被阻塞,直到有可用的许可为止。

主要方法

Semaphore(int permits):构造一个具有给定许可数量的 Semaphore。

Semaphore(int permits, boolean fair):构造一个具有给定许可数量和公平性设置的 Semaphore。公平性设置为 true 时,Semaphore 会按照线程请求许可的顺序分配许可。

void acquire():获取一个许可,如果没有可用的许可,线程将被阻塞,直到有可用的许可。

void acquire(int permits):获取指定数量的许可。

void release():释放一个许可。

void release(int permits):释放指定数量的许可。

int availablePermits():返回当前可用的许可数量。

示例代码

import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    public static void main(String[] args) {
        int threadCount = 5;
        Semaphore semaphore = new Semaphore(2); // 允许同时访问的线程数为2

        for (int i = 0; i < threadCount; i++) {
            new Thread(new Worker(semaphore)).start();
        }
    }
}

class Worker implements Runnable {
    private final Semaphore semaphore;

    public Worker(Semaphore semaphore) {
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            // 获取许可
            semaphore.acquire();
            System.out.println(Thread.currentThread().getName() + " 获取许可,开始工作");
            // 模拟工作
            Thread.sleep((long) (Math.random() * 1000));
            System.out.println(Thread.currentThread().getName() + " 释放许可,结束工作");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 释放许可
            semaphore.release();
        }
    }
}

在这个示例中,主线程创建了五个工作线程,并使用 Semaphore 来限制同时访问资源的线程数量为两个。每个工作线程在获取许可后开始工作,工作结束后释放许可。这样,最多只有两个线程可以同时访问资源。

Semaphore 用于控制对某个资源的访问数量,限制同时访问资源的线程数量。

主要方法 包括 acquire() 和 release(),分别用于获取和释放许可。

应用场景 包括限制同时访问某个资源的线程数量、实现资源池等。

48.Exchanger 了解吗?

Exchanger 是 Java 并发包(java.util.concurrent)中的一个同步点类,用于在两个线程之间交换数据。它提供了一个同步点,两个线程可以在这个同步点上交换数据。

工作原理: Exchanger 允许两个线程在同步点上交换数据。每个线程在到达同步点时,都会将自己的数据传递给对方,并接收对方的数据。这个交换操作是同步的,即两个线程必须都到达同步点,交换才会发生。

主要方法:

Exchanger():构造一个新的 Exchanger。

V exchange(V x):在同步点上与另一个线程交换数据。如果另一个线程还没有到达同步点,则当前线程将等待。

V exchange(V x, long timeout, TimeUnit unit):在同步点上与另一个线程交换数据,带有超时设置。如果在指定的时间内没有另一个线程到达同步点,则抛出 TimeoutException。

示例代码:


import java.util.concurrent.Exchanger;

public class ExchangerExample {
    public static void main(String[] args) {
        Exchanger<String> exchanger = new Exchanger<>();

        Thread thread1 = new Thread(new Worker(exchanger, "数据来自线程1"));
        Thread thread2 = new Thread(new Worker(exchanger, "数据来自线程2"));

        thread1.start();
        thread2.start();
    }
}

class Worker implements Runnable {
    private final Exchanger<String> exchanger;
    private final String data;

    public Worker(Exchanger<String> exchanger, String data) {
        this.exchanger = exchanger;
        this.data = data;
    }

    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName() + " 交换前的数据: " + data);
            String exchangedData = exchanger.exchange(data);
            System.out.println(Thread.currentThread().getName() + " 交换后的数据: " + exchangedData);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个示例中,两个线程分别创建了自己的数据,并使用 Exchanger 在同步点上交换数据。每个线程在交换前打印自己的数据,然后在交换后打印接收到的数据。

Exchanger 用于在两个线程之间交换数据

主要方法 包括 exchange(),用于在同步点上交换数据。

应用场景 包括需要在两个线程之间交换数据的场景,例如,生产者和消费者之间的数据交换。

49.ConcurrentHashMap 对 HashMap 的优化?ConcurrentHashMap 1.8 比 1.7 的优化在哪里?

ConcurrentHashMap 是 Java 并发包中的一个线程安全的哈希表实现,它对 HashMap 进行了多方面的优化,以支持高并发环境下的高效操作。

取消分段锁

在 Java 1.7 中,ConcurrentHashMap 使用分段锁,每个段有一个独立的锁。虽然这种方式提高了并发性能,但仍然存在锁竞争的问题。

在 Java 1.8 中,ConcurrentHashMap 取消了分段锁,转而使用更细粒度的锁(桶锁)和 CAS 操作。这种方式减少了锁的粒度,提高了并发性能。

引入红黑树

在 Java 1.7 中,ConcurrentHashMap 使用链表来处理哈希冲突。当链表长度较长时,查找和插入的性能会下降。

在 Java 1.8 中,当链表长度超过一定阈值时,链表会转换为红黑树,从而提高查找和插入的性能。

更高效的扩容机制

在 Java 1.8 中,ConcurrentHashMap 的扩容机制也进行了优化。扩容时,多个线程可以并发地进行数据迁移,从而提高扩容的效率。

50.为什么 ConcurrentHashMap 在 JDK 1.7 中要用 ReentrantLock,而在 JDK 1.8 要用 synchronized?

性能优化

在 JDK 1.6 之后,synchronized 的性能得到了显著优化,特别是在偏向锁和轻量级锁的情况下,synchronized 的开销非常小。

synchronized 是 JVM 内置的锁机制,能够更好地与 JVM 的其他优化机制配合。

代码简化

使用 synchronized 可以简化代码结构,使代码更易于维护和理解。

synchronized 是一种内置的锁机制,不需要显式地管理锁对象,减少了代码的复杂性。

更细粒度的锁

JDK 1.7 中的 ConcurrentHashMap 使用了分段锁机制,即 Segment 锁,每个 Segment 都是一个 ReentrantLock,这样可以保证每个 Segment 都可以独立地加锁,从而实现更高级别的并发访问。

JDK 1.8 中的 ConcurrentHashMap 使用更细粒度的锁,只对特定的桶(bucket)进行加锁,而不是整个段。这种方式减少了锁的粒度,提高了并发性能。

51.为什么 ConcurrentHashMap 比 Hashtable 效率高

ConcurrentHashMapHashtable 效率高的原因主要在于它们的锁机制和并发控制方式的不同。

Hashtable 的锁机制

Hashtable 是一个线程安全的哈希表实现,但它的线程安全是通过对整个哈希表进行同步来实现的。每次对 Hashtable 的读写操作都会锁住整个哈希表,这意味着在高并发环境下,多个线程不能同时访问 Hashtable,从而导致性能瓶颈。

public synchronized V get(Object key) {
    // 获取操作
}

public synchronized V put(K key, V value) {
    // 插入操作
}

ConcurrentHashMap 的锁机制

ConcurrentHashMap通过更细粒度的锁机制来实现线程安全,从而提高了并发性能。

JDK 1.7 中的 ConcurrentHashMap

在 JDK 1.7 中,ConcurrentHashMap 使用了分段锁(Segmented Locking)机制。整个哈希表被分成多个段(Segment),每个段是一个独立的哈希表,并且有自己的锁。这样,多个线程可以并发地访问不同段的数据,从而提高并发性能。

final Segment<K,V>[] segments;

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    // Segment 内部实现
}

JDK 1.8 中的 ConcurrentHashMap

在 JDK 1.8 中,ConcurrentHashMap摒弃了分段锁机制,转而使用更细粒度的锁和 CAS(Compare-And-Swap)操作。具体来说,JDK 1.8 中的 ConcurrentHashMap主要使用 synchronized和 CAS 操作来保证线程安全。

  • CAS 操作:CAS 是一种无锁的并发编程技术,通过比较和交换操作来保证数据的一致性。在大多数情况下,CAS 操作比锁机制更高效,因为它避免了线程的阻塞和上下文切换。
  • 细粒度锁:在插入新节点时,只对特定的桶(bucket)进行加锁,而不是整个哈希表。这种方式减少了锁的粒度,提高了并发性能。
static final class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
}

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    // 使用 synchronized 和 CAS 操作进行数据迁移
    synchronized (f) {
        // 迁移逻辑
    }
}
  • 锁粒度Hashtable 对整个哈希表进行同步,而 ConcurrentHashMap使用更细粒度的锁(分段锁或桶锁),从而允许更高的并发访问。
  • 性能优化ConcurrentHashMap 在 JDK 1.8 中使用了 CAS 操作和更细粒度的锁,进一步提高了并发性能。
  • 代码简化ConcurrentHashMap 在 JDK 1.8 中使用 synchronized 和 CAS 操作,简化了代码结构,并利用 JVM 的优化机制提高性能。

因此,ConcurrentHashMapHashtable 在高并发环境下具有更高的效率和更好的性能表现。