Java并发编程面试篇-1

101 阅读3分钟

1,什么是threadLocal ,threadLocal是如何实现的?

2,threadLocal 内存泄露问题

3,进程和线程的区别,进程间如何通信

4,什么是上下文切换

5,什么是死锁死锁的必要条件

6,synchionized与 lock 的区别

7,什么是AQS?

8, 线程池如何知道一个线程的任务已经执行完成

9,什么叫阻塞队列的有界和无界

10、concurrentHashMap底层具体实现知道吗? 实现原理是什么?

11、能谈一下CAS 机制吗?

12、volatile 关键字有什么作用? 底层实现原理是什么?

13、讲一下wait notify 为什么要在synchronized 代码块中?

14、ArrayBlockingQueue实现原理

15、怎么理解线程安全?

16、什么是可重入,什么是可重入锁?它用来解决什么问题? 实现原理

一、什么是threadLocal ,threadLocal是如何实现的?

ThreadLocal是 Java 中的一个线程局部变量类,用于在多线程环境下为每个线程提供独立的变量副本,使得每个线程都可以独立地修改和访问自己的变量副本,而不会影响其他线程的同名变量,从而实现了线程间的数据隔离。以下是对ThreadLocal的详细介绍以及其实现原理:

ThreadLocal 的使用方法

  • 首先,需要创建一个ThreadLocal对象,并通过其set()方法为当前线程设置变量副本的值。例如:

    ThreadLocal threadLocal = new ThreadLocal<>(); threadLocal.set(10);

  • 然后,在同一个线程的其他地方,可以通过get()方法获取该线程对应的变量副本的值。例如:

    int value = threadLocal.get(); System.out.println(value);

  • 当线程执行完毕后,可以通过remove()方法清除该线程对应的变量副本,以避免内存泄漏。例如:

    threadLocal.remove();

ThreadLocal 的实现原理

  • 线程局部存储(Thread Local Storage,TLS)ThreadLocal的实现基于线程局部存储的概念。每个线程在其内部都维护了一个类似于哈希表的数据结构,用于存储该线程的所有ThreadLocal变量及其对应的值。这个数据结构是线程私有的,不同线程之间相互独立,从而实现了线程间的数据隔离。
  • ThreadLocalMap:在 Java 中,Thread类内部有一个成员变量threadLocals,它的类型是ThreadLocal.ThreadLocalMapThreadLocalMap是一个定制化的哈希表,用于存储ThreadLocal对象和其对应的变量值。ThreadLocalMap中的每个键值对由ThreadLocal对象作为键,变量副本的值作为值。
  • 内存泄漏问题及解决:由于ThreadLocalMap中的键值对是强引用关系,如果ThreadLocal对象没有被正确地清理,可能会导致内存泄漏。例如,当一个线程执行完毕后,如果其对应的ThreadLocal变量没有被显式地删除,那么该ThreadLocal对象及其对应的变量副本将一直占用内存,直到线程被销毁。为了解决这个问题,ThreadLocal提供了remove()方法,用于在使用完ThreadLocal变量后及时清除对应的键值对,释放内存。

ThreadLocal 的核心方法实现

  • set () 方法:当调用ThreadLocalset()方法时,实际上是在当前线程的ThreadLocalMap中设置键值对。首先,通过Thread.currentThread()获取当前线程对象,然后获取其内部的ThreadLocalMap。如果ThreadLocalMap不为空,则直接将当前ThreadLocal对象作为键,要设置的值作为值,放入ThreadLocalMap中;如果ThreadLocalMap为空,则创建一个新的ThreadLocalMap,并将键值对放入其中。以下是简化的set()方法的实现逻辑:

    public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map!= null) map.set(this, value); else createMap(t, value); }

    ThreadLocalMap getMap(Thread t) { return t.threadLocals; }

    void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); }

  • get () 方法get()方法用于获取当前线程对应的ThreadLocal变量副本的值。同样,先获取当前线程的ThreadLocalMap,然后以当前ThreadLocal对象为键,从ThreadLocalMap中获取对应的变量值。如果不存在,则返回默认值(对于基本数据类型的ThreadLocal,默认值为其对应类型的默认初始值;对于对象类型的ThreadLocal,默认值为null)。以下是简化的get()方法的实现逻辑:

    public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map!= null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e!= null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); }

    private T setInitialValue() { T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map!= null) map.set(this, value); else createMap(t, value); return value; }

    protected T initialValue() { return null; }

  • remove () 方法remove()方法用于清除当前线程对应的ThreadLocal变量副本。它通过获取当前线程的ThreadLocalMap,然后以当前ThreadLocal对象为键,从ThreadLocalMap中删除对应的键值对,从而释放内存,避免内存泄漏。以下是简化的remove()方法的实现逻辑:

    public void remove() { ThreadLocalMap m = getMap(Thread.currentThread()); if (m!= null) m.remove(this); }

应用场景

  • 多线程环境下的用户身份信息传递:在 Web 应用程序中,不同的请求可能由不同的线程处理,而每个请求都可能需要访问当前用户的身份信息。通过使用ThreadLocal,可以在请求处理的开始阶段将用户身份信息存储到ThreadLocal变量中,然后在整个请求处理过程中的各个方法和组件中都可以方便地获取到该用户身份信息,而无需在方法之间显式地传递参数,同时也保证了不同请求之间的用户身份信息的隔离性。
  • 数据库连接管理:在多线程的数据库操作中,为了避免频繁地创建和销毁数据库连接,可以使用ThreadLocal来管理数据库连接。每个线程在第一次访问数据库时,创建一个数据库连接并存储到ThreadLocal变量中,后续在该线程中对数据库的操作都使用同一个连接,直到线程执行完毕后再关闭连接。这样可以提高数据库连接的复用率,减少连接创建和销毁的开销,同时保证了不同线程之间使用的数据库连接的独立性。
  • 线程安全的计数器:在多线程环境下,如果需要为每个线程维护一个独立的计数器,可以使用ThreadLocal来实现。每个线程都有自己的计数器变量副本,通过ThreadLocalset()get()方法来进行计数器的增加和获取操作,而不会影响其他线程的计数器值,从而实现了线程安全的计数器功能。

ThreadLocal通过为每个线程提供独立的变量副本,有效地解决了多线程环境下的数据共享和线程安全问题,在许多需要线程间数据隔离的场景中有着广泛的应用。但在使用过程中,也需要注意及时清理ThreadLocal变量,以避免内存泄漏等潜在问题。

总的来说使用ThreadLocal 主要解决参数传递问题, 以及并发问题

二、ThreadLocal 内存泄露问题?

ThreadLocal内存泄露问题是在使用ThreadLocal时需要特别关注的一个重要问题,以下是对该问题的详细介绍:

内存泄露产生的原因

  • 强引用关系ThreadLocal内存泄露问题的根源在于ThreadLocalMap中使用了强引用。在ThreadLocal的实现中,每个线程都有一个对应的ThreadLocalMap,用于存储该线程的ThreadLocal变量及其对应的值。ThreadLocalMap中的每个键值对由ThreadLocal对象作为键,变量副本的值作为值,并且这些引用都是强引用。这意味着只要线程不结束,ThreadLocalMap及其内部的所有键值对都不会被垃圾回收器回收,即使ThreadLocal变量已经不再被使用,但由于其强引用关系,仍然会占用内存空间,从而导致内存泄露。
  • 线程的生命周期长于ThreadLocal的使用周期:在一些场景中,如使用线程池时,线程会被反复利用来执行不同的任务。如果在每次任务执行过程中使用了ThreadLocal并存储了一些对象,但在任务结束后没有及时清理ThreadLocal变量,那么随着时间的推移,ThreadLocalMap中就会积累大量不再使用的ThreadLocal变量及其对应的对象,这些对象占用的内存无法被释放,最终导致内存泄露。即使在非线程池的情况下,如果线程的执行时间较长或者存在一些复杂的逻辑导致线程没有及时结束,同样也会出现类似的内存泄露问题。

内存泄露的影响

  • 内存占用不断增加:随着时间的推移和应用程序的持续运行,由于ThreadLocal内存泄露的存在,内存中会积累越来越多无法被回收的对象,导致可用内存逐渐减少。这可能会引发内存不足的错误,影响应用程序的正常运行,甚至导致系统崩溃。
  • 性能下降:大量的内存泄露会导致垃圾回收器的工作负担加重,因为垃圾回收器需要不断地扫描和处理这些无法被回收的对象。这会增加垃圾回收的时间和频率,导致应用程序的性能下降,响应时间变长,吞吐量降低。

解决内存泄露的方法

  • 及时调用remove()方法:这是最常见和有效的解决ThreadLocal内存泄露问题的方法。在使用完ThreadLocal变量后,应该及时调用remove()方法来清除当前线程对应的ThreadLocal变量副本。这样可以确保ThreadLocal对象及其对应的引用能够被及时释放,避免内存泄露。例如:

    ThreadLocal threadLocal = new ThreadLocal<>(); try { threadLocal.set(10); // 使用threadLocal的业务逻辑 } finally { threadLocal.remove(); }

在上述代码中,无论在try块中是否发生异常,最终都会在finally块中调用remove()方法来清除ThreadLocal变量,确保内存的正确释放。

  • 弱引用的使用(不常见且有局限性):有一种不太常见的解决方法是使用弱引用代替ThreadLocalMap中的强引用。通过将ThreadLocal对象在ThreadLocalMap中的引用设置为弱引用,当ThreadLocal对象没有其他强引用指向它时,垃圾回收器可以自动回收ThreadLocal对象,从而避免内存泄露。然而,这种方法存在一些局限性,因为虽然ThreadLocal对象可以被及时回收,但与之对应的变量值仍然可能无法被释放,除非在ThreadLocalget()set()remove()方法中添加额外的逻辑来处理弱引用的情况,这会增加代码的复杂性和潜在的错误风险,并且在实际应用中并不常见。

综上所述,ThreadLocal内存泄露问题是由于其内部强引用关系以及线程和ThreadLocal使用周期的不一致性导致的。开发人员在使用ThreadLocal时,应该养成良好的编程习惯,及时调用remove()方法来避免内存泄露,确保应用程序的性能和稳定性。

三、进程和线程的区别,进程间如何通信

进程和线程是操作系统中两个重要的概念,它们在资源分配、执行方式和调度等方面存在诸多区别,以下是对它们区别的详细介绍以及常见的进程间通信方式:

进程和线程的区别

  • 资源分配
    • 进程:进程是资源分配的基本单位,每个进程都有自己独立的地址空间,包括代码段、数据段、堆、栈等,操作系统会为每个进程分配所需的系统资源,如内存、CPU 时间片、文件描述符等。不同进程之间的资源是相互隔离的,一个进程无法直接访问另一个进程的资源。
    • 线程:线程是进程中的一个执行单元,是 CPU 调度和执行的基本单位。线程共享所属进程的资源,包括地址空间、打开的文件、网络连接等。多个线程可以并发地访问和修改进程中的共享数据,但需要注意线程间的同步和互斥问题,以避免数据不一致性。
  • 执行方式
    • 进程:进程之间是相对独立的,每个进程都有自己的执行流程和程序计数器,它们可以并发地执行,但进程间的切换相对开销较大,因为涉及到整个地址空间的切换和系统资源的重新分配。
    • 线程:线程是在进程内部执行的,多个线程共享进程的地址空间和其他资源,它们之间的切换相对开销较小,只需要保存和恢复线程的上下文信息,如程序计数器、寄存器等,就可以快速地切换到另一个线程执行。
  • 独立性
    • 进程:不同进程之间具有较高的独立性,一个进程的崩溃通常不会影响其他进程的正常运行,除非它们之间存在特定的依赖关系或通信机制。每个进程都有自己独立的运行环境和状态,彼此之间相对隔离。
    • 线程:线程是进程的一部分,一个线程的异常可能会导致整个进程的崩溃,因为它们共享进程的资源和地址空间。线程之间的独立性相对较弱,需要通过适当的同步机制来协调对共享资源的访问,以确保程序的正确性和稳定性。
  • 调度
    • 进程:操作系统的进程调度器负责将 CPU 时间片分配给不同的进程,以实现多个进程的并发执行。进程调度的粒度相对较粗,通常以进程为单位进行调度,根据进程的优先级、等待时间等因素来决定哪个进程获得 CPU 资源。
    • 线程:线程调度是在进程内部进行的,操作系统的线程调度器会根据线程的优先级、等待时间等因素,将 CPU 时间片分配给进程中的不同线程,以实现线程的并发执行。线程调度的粒度相对较细,可以更灵活地利用 CPU 资源,提高系统的并发性能。

进程间通信方式

  • 管道(Pipe)
    • 匿名管道:通常用于具有亲缘关系的进程之间的通信,如父子进程。它是一种半双工的通信方式,数据只能单向流动,一个进程向管道写入数据,另一个进程从管道读取数据。匿名管道在创建时会返回两个文件描述符,分别用于读和写操作,通信双方通过这两个文件描述符进行数据传输。例如,在 shell 中,可以使用 | 符号创建匿名管道,将一个命令的输出作为另一个命令的输入。
    • 命名管道(FIFO):克服了匿名管道只能用于亲缘关系进程的限制,可以在不相关的进程之间进行通信。命名管道是一种特殊类型的文件,在文件系统中有对应的文件名,不同的进程可以通过打开这个命名管道文件来进行通信。通信方式与匿名管道类似,也是半双工的,但可以通过打开多个文件描述符来实现全双工通信。
  • 信号量(Semaphore):信号量是一个计数器,用于控制多个进程对共享资源的访问。它可以实现进程间的同步和互斥,通过对信号量的操作来协调进程的执行顺序和对共享资源的访问权限。信号量的值表示可用资源的数量,当进程需要访问共享资源时,先对信号量进行 P 操作(减 1 操作,如果信号量的值大于等于 1,则继续执行,否则阻塞等待),访问完资源后再进行 V 操作(加 1 操作,唤醒等待的进程)。
  • 共享内存(Shared Memory):多个进程可以将同一块内存区域映射到自己的地址空间中,从而实现对共享内存的直接访问和数据共享。共享内存是一种高效的进程间通信方式,因为它避免了数据在不同进程之间的复制开销。但需要使用同步机制来确保多个进程对共享内存的正确访问,防止数据冲突和不一致性。常见的同步机制包括信号量、互斥锁等。
  • 消息队列(Message Queue):消息队列是一个由消息组成的链表,进程可以向消息队列中发送消息,也可以从消息队列中接收消息。消息队列提供了一种异步的通信方式,发送方和接收方不需要同时运行,消息会被保存在队列中,直到被接收方取走。消息队列通常具有一定的优先级和消息类型,可以根据不同的需求进行设置和处理。
  • 套接字(Socket):套接字主要用于不同主机之间的进程通信,也可以用于同一主机上的进程间通信。它提供了一种通用的网络编程接口,通过 IP 地址和端口号来标识不同的进程和服务。套接字通信可以基于 TCP 协议实现可靠的面向连接的通信,也可以基于 UDP 协议实现高效的无连接通信。在网络应用程序中,如客户端 - 服务器架构的应用,通常使用套接字来实现进程间的通信。

进程和线程在操作系统中各自扮演着重要的角色,理解它们之间的区别以及掌握常见的进程间通信方式,对于开发多进程和多线程的应用程序具有重要意义。不同的进程间通信方式适用于不同的应用场景,开发人员需要根据具体的需求和系统特点选择合适的通信方式来实现进程间的协作和数据共享。

四、什么是线程上下文切换

线程上下文切换是指在多线程操作系统中,CPU 从一个线程的执行切换到另一个线程执行的过程。在这个过程中,操作系统需要保存当前线程的执行上下文,并恢复另一个线程的执行上下文,以便让新的线程能够继续执行下去。以下是关于线程上下文切换的详细介绍:

执行上下文的组成

  • 程序计数器:记录当前线程正在执行的指令位置,以便在切换回来时能够继续从上次中断的地方执行。
  • 寄存器:包括通用寄存器、指令指针寄存器、栈指针寄存器等,这些寄存器存储了线程执行过程中的临时数据和状态信息。
  • 栈信息:每个线程都有自己的栈,用于存储局部变量、函数调用的参数和返回地址等。在上下文切换时,需要保存和恢复当前线程的栈指针,以确保线程的函数调用和局部变量的正确访问。
  • 线程状态:包括线程的运行状态(如就绪、运行、阻塞等)、优先级等信息,这些信息决定了线程在切换后的执行顺序和调度策略。

引发线程上下文切换的原因

  • 时间片用完:操作系统为每个线程分配一定的 CPU 时间片,当一个线程的时间片用完后,操作系统会暂停该线程的执行,将 CPU 资源切换到其他就绪线程,以实现多个线程的并发执行。
  • 线程阻塞:当线程在执行过程中需要等待某些事件的发生,如等待 I/O 操作完成、等待获取锁等,线程会进入阻塞状态。此时,操作系统会暂停该线程的执行,将 CPU 资源切换到其他就绪线程,以提高 CPU 的利用率。
  • 线程优先级调整:如果系统中存在多个线程,并且线程的优先级发生了变化,操作系统可能会根据新的优先级调整线程的执行顺序,从而引发线程上下文切换。优先级高的线程会优先获得 CPU 资源,当高优先级线程变为就绪状态时,操作系统可能会暂停当前正在执行的低优先级线程,切换到高优先级线程执行。
  • 多处理器系统中的负载均衡:在多处理器系统中,为了充分利用多个 CPU 核心的资源,操作系统会根据各个 CPU 核心的负载情况,动态地将线程分配到不同的 CPU 核心上执行。当发现某个 CPU 核心的负载过高,而其他 CPU 核心的负载较低时,操作系统可能会将一些线程从负载高的 CPU 核心切换到负载低的 CPU 核心上执行,以实现负载均衡,提高系统的整体性能。

线程上下文切换的开销

  • 时间开销:上下文切换本身需要一定的时间来完成,包括保存当前线程的上下文信息和恢复另一个线程的上下文信息。在这个过程中,CPU 需要执行一系列的指令来进行数据的存储和恢复操作,这会占用一定的 CPU 时间,导致系统的整体性能下降。
  • 空间开销:上下文切换需要保存和恢复线程的各种状态信息,这些信息通常需要占用一定的内存空间。如果系统中存在大量的线程上下文切换,可能会导致内存的频繁分配和释放,增加内存管理的开销。
  • 缓存失效:现代 CPU 通常具有高速缓存,用于缓存最近访问的数据和指令,以提高 CPU 的访问速度。当发生线程上下文切换时,新的线程可能会使用不同的数据和指令,导致 CPU 缓存中的数据失效,需要重新从内存中加载数据,这会增加 CPU 的访问延迟,进一步降低系统的性能。

减少线程上下文切换开销的方法

  • 合理设置线程数量:避免创建过多不必要的线程,因为线程数量过多会导致 CPU 时间片的频繁切换,增加上下文切换的开销。根据系统的硬件资源和业务需求,合理地确定线程池的大小,以充分利用 CPU 资源,同时减少不必要的上下文切换。
  • 避免频繁的阻塞操作:尽量减少线程在执行过程中的阻塞时间,例如优化 I/O 操作,使用异步 I/O 或非阻塞 I/O 方式,避免线程长时间等待 I/O 完成而导致上下文切换。另外,合理地使用锁和同步机制,避免线程之间的过度竞争和阻塞,提高线程的执行效率。
  • 使用线程绑定:在多处理器系统中,可以将线程绑定到特定的 CPU 核心上执行,避免线程在不同的 CPU 核心之间频繁切换,从而减少缓存失效的开销,提高系统的性能。一些操作系统提供了相应的接口或工具来实现线程绑定功能。
  • 优化线程调度算法:操作系统的线程调度算法对上下文切换的频率和开销有很大的影响。通过优化线程调度算法,如采用更智能的优先级调度算法、时间片动态调整算法等,可以减少不必要的上下文切换,提高系统的整体性能。

线程上下文切换是多线程操作系统实现多线程并发执行的重要机制,但它也会带来一定的开销。了解线程上下文切换的原理和开销,以及采取相应的优化措施,对于提高多线程程序的性能和系统的整体效率具有重要意义。

五、什么是死锁?死锁的必要条件

死锁是指在多线程或多进程环境下,两个或多个线程或进程在执行过程中,因争夺资源而造成的一种互相等待的状态,若无外力作用,它们都将无法继续推进下去。例如,线程 A 持有资源 R1 并等待获取资源 R2,而线程 B 持有资源 R2 并等待获取资源 R1,此时就发生了死锁。以下是死锁产生的四个必要条件:

互斥条件

  • 定义:指资源在某一时刻只能被一个进程或线程所使用,即资源的独占性。如果一个资源已经被某个进程或线程占用,其他进程或线程就不能同时使用该资源,必须等待其释放。
  • 示例:在一个系统中,打印机是一种独占资源,同一时刻只能有一个进程使用打印机进行打印操作。如果进程 A 正在使用打印机,那么进程 B 就必须等待进程 A 释放打印机后才能使用。

请求与保持条件

  • 定义:进程或线程在已经持有了至少一个资源的情况下,又提出了新的资源请求,而该新资源已被其他进程或线程占有,此时请求进程或线程被阻塞,但又对自己已获得的其他资源保持不放。
  • 示例:进程 A 已经获得了资源 R1,在执行过程中又需要资源 R2,但资源 R2 被进程 B 占用,此时进程 A 被阻塞,并且不会释放已经持有的资源 R1,而是一直等待资源 R2 被释放。

不可剥夺条件

  • 定义:指进程或线程所获得的资源在未使用完之前,不能被其他进程或线程强行剥夺,只能由其自身主动释放。
  • 示例:假设进程 A 获得了数据库连接资源,在其完成数据库操作之前,其他进程不能强行剥夺该数据库连接资源,即使其他进程也需要使用该资源,也只能等待进程 A 主动释放。

环路等待条件

  • 定义:存在一个进程或线程等待序列 {P1, P2, …, Pn},其中 P1 等待 P2 占用的资源,P2 等待 P3 占用的资源,……,Pn 等待 P1 占用的资源,形成了一个资源等待环路。
  • 示例:有三个进程 A、B、C,进程 A 持有资源 R1 并等待资源 R2,而资源 R2 被进程 B 持有,进程 B 又等待资源 R3,资源 R3 被进程 C 持有,进程 C 则等待资源 R1,这样就形成了一个环路等待的死锁情况。

只有当这四个条件同时满足时,才会发生死锁。理解死锁的这些必要条件,对于预防、避免和检测死锁具有重要意义,开发人员可以通过破坏这些条件中的一个或多个来防止死锁的发生。

预防死锁

  • 破坏互斥条件:如果能够打破资源的互斥性,使多个进程或线程可以同时访问同一资源,就可以预防死锁的发生。然而,在很多情况下,资源的互斥性是由其本身的性质决定的,难以改变。例如,打印机、磁带机等物理设备在同一时刻只能被一个进程使用,对于这类资源无法破坏其互斥条件。但对于一些软件资源,可以通过采用可重入代码等方式来实现部分资源的共享,从而在一定程度上破坏互斥条件。
  • 破坏请求与保持条件:可以要求进程或线程在开始执行前一次性申请其所需的所有资源,只有当所有资源都申请成功时,才开始执行;在执行过程中,不再申请新的资源。这样就避免了进程在持有部分资源的同时又去请求其他被占用的资源,从而破坏了请求与保持条件。例如,在一个数据库系统中,要求事务在开始执行前一次性申请所有需要用到的数据库锁,而不是在执行过程中逐步申请,从而防止死锁的发生。
  • 破坏不可剥夺条件:当一个进程或线程持有某些资源并等待其他资源时,如果其等待时间超过一定的阈值,可以强制剥夺其已经持有的资源,分配给其他需要该资源的进程或线程。待该进程或线程再次具备获得资源的条件时,重新为其分配资源。这种方法需要操作系统提供相应的支持,并且在实施过程中要注意资源的恢复和重新分配的正确性,以避免数据丢失或不一致等问题。
  • 破坏环路等待条件:可以通过对资源进行编号,并规定进程或线程按照一定的顺序申请资源,即按照资源编号从小到大的顺序依次申请。这样就可以避免出现环路等待的情况,从而破坏了死锁产生的环路等待条件。例如,系统中有资源 R1、R2、R3,分别编号为 1、2、3,规定进程必须先申请编号小的资源,再申请编号大的资源,那么就不会出现进程持有 R3 等待 R1,同时另一个进程持有 R1 等待 R3 的环路等待情况。

避免死锁

  • 银行家算法:银行家算法是一种经典的死锁避免算法,它通过对系统资源的分配和进程对资源的请求进行动态监测和控制,来确保系统始终处于安全状态,即系统能够找到一种资源分配序列,使得每个进程都能够顺利完成而不会发生死锁。在银行家算法中,系统需要事先知道每个进程对各类资源的最大需求、已分配的资源数量以及系统中各类资源的剩余数量等信息。当进程提出资源请求时,系统会根据当前的资源分配情况和进程的需求,判断如果满足该请求是否会导致系统进入不安全状态。如果不会导致不安全状态,则分配资源给该进程;否则,拒绝该请求,让进程等待。
  • 资源分配图化简法:资源分配图是一种用于描述系统中进程和资源分配关系的有向图。通过对资源分配图进行化简,可以判断系统当前是否处于死锁状态或是否可能发生死锁。化简的过程是不断地寻找可以释放资源的进程,即那些已经获得了所有所需资源并且可以执行完毕的进程,将其占用的资源释放给系统,然后再看是否有其他进程能够因此而获得足够的资源继续执行。如果最终所有的进程都能够通过这种方式得到所需资源并执行完毕,那么系统处于安全状态;否则,系统可能会发生死锁。

检测与解除死锁

  • 死锁检测算法:通过系统的资源分配图、进程等待矩阵等数据结构,运用特定的算法来检测系统中是否存在死锁。常见的死锁检测算法有资源分配图的化简算法、等待图算法等。这些算法会定期或在系统出现异常时运行,以确定是否有一组进程处于死锁状态。一旦检测到死锁,系统需要采取相应的措施来解除死锁。
  • 死锁解除方法
    • 资源剥夺法:剥夺死锁进程所占用的资源,将这些资源分配给其他等待该资源的进程,以解除死锁。但这种方法可能会导致被剥夺资源的进程之前的工作失效,需要在适当的时候恢复其工作状态。
    • 撤销进程法:强制撤销部分或全部处于死锁状态的进程,剥夺它们的资源,并将这些资源分配给其他非死锁进程,以打破死锁。撤销进程时需要考虑进程的优先级、已执行的时间等因素,尽量选择撤销代价较小的进程。
    • 进程回退法:让一个或多个死锁进程回退到足以打破死锁的某个检查点之前的状态,释放它们所占用的资源,然后重新分配这些资源,使系统能够继续正常运行。这种方法需要系统记录进程的执行历史和检查点信息,以便能够准确地回退进程。

死锁问题在多线程或多进程系统中较为复杂,不同的解决方法适用于不同的场景和系统需求。在实际应用中,需要根据具体的情况选择合适的方法来预防、避免或解除死锁,以确保系统的稳定运行。

六、Synchronized 与 lock 区别

synchronizedLock都是 Java 中用于实现多线程同步的机制,但它们在使用方式、功能特性和性能等方面存在一些区别,以下是详细介绍:

语法和使用方式

  • synchronized

    • synchronized是 Java 中的关键字,可以修饰方法、代码块。当修饰方法时,整个方法体都被视为同步代码块,同一时刻只有一个线程能够访问该方法;当修饰代码块时,需要指定一个对象作为锁对象,只有获取到该锁对象的线程才能执行同步代码块中的代码。例如:

    // 修饰方法 public synchronized void synchronizedMethod() { // 同步方法体 }

    // 修饰代码块 public void someMethod() { synchronized (this) { // 同步代码块 } }

  • Lock

    • Lock是一个接口,位于java.util.concurrent.locks包中,常见的实现类有ReentrantLock等。使用Lock时,需要先创建Lock对象,然后通过调用lock()方法获取锁,在执行完同步代码后,再调用unlock()方法释放锁。例如:

    import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock;

    public class LockExample { private final Lock lock = new ReentrantLock();

    public void someMethod() {
        lock.lock();
        try {
            // 同步代码
        } finally {
            lock.unlock();
        }
    }
    

    }

功能特性

  • 等待可中断性
    • synchronized:当一个线程获取不到synchronized关键字修饰的锁时,它会一直等待下去,直到获取到锁为止,无法中断等待。
    • LockLock提供了lockInterruptibly()方法,当线程在等待获取锁的过程中,可以被其他线程通过调用interrupt()方法中断等待,从而避免了线程长时间的无效等待。
  • 公平性
    • synchronizedsynchronized是非公平锁,即无法保证等待时间最长的线程一定能够最先获取到锁。多个线程在竞争锁时,有可能后请求锁的线程先获取到锁。
    • LockLock可以通过构造函数来指定是否为公平锁。公平锁会按照线程请求锁的顺序来分配锁,先请求的线程先获得锁,能够保证等待时间最长的线程优先获取到锁,但公平锁的性能相对较低,因为需要维护一个等待队列。
  • 锁绑定多个条件
    • synchronizedsynchronized关键字与对象的内置锁绑定,一个锁只能有一个等待队列,无法实现多个条件的等待和唤醒。
    • LockLock可以通过newCondition()方法创建多个Condition对象,每个Condition对象都可以对应一个等待队列,从而实现更精细的线程间通信和同步控制。例如,可以根据不同的业务条件,让线程在不同的Condition等待队列中等待,然后根据不同的条件唤醒相应的线程。

性能表现

  • synchronized:在 Java 早期版本中,synchronized的性能相对较差,但在 Java 6 及以后的版本中,对synchronized进行了大量的优化,其性能有了显著提升。在大多数情况下,性能已经与Lock相当,甚至在一些简单的场景下,由于其简洁性和编译器的优化,性能可能会更好。
  • LockLock的性能在不同的场景下表现有所不同。在高并发、竞争激烈的场景下,如果合理地使用Lock的高级特性,如公平锁、可中断锁等,能够更好地控制线程的并发访问,提高系统的整体性能和响应能力。但如果使用不当,如频繁地获取和释放锁,可能会导致性能下降。

适用场景

  • synchronized:适用于简单的、对性能要求不是特别苛刻的多线程同步场景,尤其是在代码结构较为简单、同步逻辑不复杂的情况下,使用synchronized关键字能够简洁明了地实现线程同步。例如,对单个共享资源的简单访问控制,如对一个计数器的原子操作等。
  • Lock:适用于对性能和灵活性要求较高的多线程同步场景,特别是在需要实现复杂的线程间协作和同步控制时,如生产者 - 消费者模式、读写锁分离等场景,使用Lock及其相关的Condition对象能够更灵活地实现线程的等待、唤醒和同步控制,提高系统的并发性能和可靠性。

synchronizedLock都有各自的特点和优势,开发人员需要根据具体的业务需求、性能要求和代码结构等因素来选择合适的同步机制。

七、什么是AQS

AQS 即 AbstractQueuedSynchronizer,是 Java 中用于构建锁和同步器的基础框架,许多并发包中的类如ReentrantLockCountDownLatchSemaphore等都是基于 AQS 实现的。以下是对 AQS 的详细介绍:

AQS 的基本原理

  • 核心数据结构:AQS 内部使用了一个双向队列来管理等待获取同步状态的线程,该队列被称为同步队列。同步队列中的节点(Node)包含了线程引用、等待状态等信息,通过 prev 和 next 指针维护节点之间的关系。同时,AQS 还维护了一个表示同步状态的整数变量state,不同的子类可以根据具体的业务需求对state进行不同的解释和操作。例如,在ReentrantLock中,state表示锁的持有次数;在CountDownLatch中,state表示需要等待的事件数量。
  • 获取与释放同步状态:线程通过调用 AQS 的acquire方法来尝试获取同步状态,如果获取失败,则将当前线程包装成一个节点插入到同步队列的尾部,并阻塞当前线程。当同步状态可用时,同步队列中的头节点所对应的线程会被唤醒,尝试再次获取同步状态。线程获取到同步状态后,可以执行相应的临界区代码,执行完毕后通过调用release方法释放同步状态,唤醒同步队列中的下一个节点所对应的线程,从而实现线程之间的同步。

AQS 的主要方法

  • acquire(int arg):该方法是获取同步状态的入口方法,它会调用子类实现的tryAcquire方法尝试以独占方式获取同步状态。如果获取成功,则直接返回;如果获取失败,则将当前线程包装成节点插入同步队列,并调用acquireQueued方法使线程在队列中等待,直到获取到同步状态为止。
  • release(int arg):该方法用于释放同步状态,它会调用子类实现的tryRelease方法尝试释放同步状态。如果释放成功,并且同步队列中存在等待的节点,则唤醒同步队列中的头节点的后继节点所对应的线程,使其有机会获取同步状态。
  • acquireShared(int arg):用于以共享方式获取同步状态,与acquire方法类似,但允许多个线程同时获取同步状态。它会调用子类实现的tryAcquireShared方法尝试获取同步状态,如果获取成功,则根据返回值判断是否还有剩余的同步状态可供其他线程获取,并相应地唤醒等待的线程;如果获取失败,则将当前线程插入同步队列等待。
  • releaseShared(int arg):用于以共享方式释放同步状态,会调用子类实现的tryReleaseShared方法尝试释放同步状态。如果释放成功,并且同步队列中存在等待获取共享同步状态的节点,则唤醒这些节点所对应的线程,使它们有机会获取同步状态。

AQS 的模板方法设计模式

AQS 采用了模板方法设计模式,它定义了一系列的模板方法,如acquirerelease等,这些方法实现了获取和释放同步状态的基本流程,但具体的获取和释放逻辑则由子类通过重写抽象方法tryAcquiretryRelease等来实现。这种设计模式使得 AQS 具有很高的可扩展性和灵活性,开发人员可以根据不同的同步需求,通过继承 AQS 并实现特定的抽象方法来创建各种类型的锁和同步器。

基于 AQS 实现的常见同步器

  • ReentrantLock:是一个可重入的互斥锁,它通过重写 AQS 的tryAcquiretryRelease方法来实现锁的获取和释放逻辑。在ReentrantLock中,state表示锁的持有次数,线程首次获取锁时,state加 1,再次获取锁时,state会继续累加,释放锁时,state减 1,当state减为 0 时,锁被完全释放。
  • CountDownLatch:用于实现一个或多个线程等待其他多个线程完成某件事情后再继续执行的场景。它通过重写 AQS 的tryAcquireSharedtryReleaseShared方法来实现。在CountDownLatch中,state表示需要等待的事件数量,当一个事件完成时,通过调用countDown方法使state减 1,当state减为 0 时,所有等待的线程会被唤醒。
  • Semaphore:用于控制同时访问某个资源的线程数量。它通过重写 AQS 的tryAcquireSharedtryReleaseShared方法来实现。在Semaphore中,state表示可用的许可证数量,线程获取许可证时,state减 1,释放许可证时,state加 1,当state小于 0 时,获取许可证的线程会被阻塞。

AQS 作为 Java 并发包中的核心基础框架,为实现各种复杂的锁和同步器提供了强大的支持,通过其模板方法设计模式和灵活的状态管理机制,使得开发人员能够方便地创建满足不同并发需求的同步工具,提高了多线程程序的开发效率和可靠性。

八、线程池如何知道线程任务已经执行完成

  1. 在线程池内部,当我们把一个任务丢给线程池去执行,线程池会调度工作线程来执行这个任务的 run 方法,run 方法正常结束,也就意味着任务完成了。所以线程池中的工作线程是通过同步调用任务的 run()方法并且等待 run 方法返回后,再去统计任务的完成数量。
  2. 如果想在线程池外部去获得线程池内部任务的执行状态,有几种方法可以实现。
  • 线程池提供了一个 isTerminated()方法,可以判断线程池的运行状态,我们可以循环判断 isTerminated()方法的返回结果来了解线程池的运行状态,一旦线程池的运行状态是 Terminated,意味着线程池中的所有任务都已经执行完了。想要通过这个方法获取状态的前提是,程序中主动调用了线程池的 shutdown()方法。在实际业务中,一般不会主动去关闭线程池,因此这个方法在实用性和灵活性方面都不是很好。
  • 在线程池中,有一个 submit()方法,它提供了一个 Future 的返回值,我们通过 Future.get()方法来获得任务的执行结果,当线程池中的任务没执行完之前,future.get()方法会一直阻塞,直到任务执行结束。因此,只要 future.get()方法正常返回,也就意味着传入到线程池中的任务已经执行完成了!
  • 可以引入一个 CountDownLatch 计数器,它可以通过初始化指定一个计数器进行倒计时,其中有两个方法分别是 await()阻塞线程,以及 countDown()进行倒计时,一旦倒计时归零,所以被阻塞在 await()方法的线程都会被释放。基于这样的原理,我们可以定义一个 CountDownLatch 对象并且计数器为 1,接着在线程池代码块后面调用 await()方法阻塞主线程,然后,当传入到线程池中的任务执行完成后,调用 countDown()方法表示任务执行结束。最后,计数器归零 0,唤醒阻塞在 await()方法的线程。

九、什么叫阻塞队列的有界和无界

  1. 基本概念
    • 阻塞队列(Blocking Queue):是一种支持两个附加操作的队列。这两个附加操作是:在队列为空时,获取元素的操作会被阻塞;在队列满时,添加元素的操作会被阻塞。它主要用于生产者 - 消费者模式等多线程场景,能够有效地协调生产者和消费者之间的工作节奏。
  2. 有界阻塞队列(Bounded Blocking Queue)
    • 定义与容量限制:有界阻塞队列是指具有固定容量的阻塞队列。例如,ArrayBlockingQueue是一个典型的有界阻塞队列,在创建时需要指定队列的容量大小。一旦队列中的元素数量达到这个指定的容量,生产者线程再尝试向队列中添加元素时,就会被阻塞,直到消费者线程从队列中取出元素,腾出空间为止。
    • 内存使用和资源管理优势:由于其容量是固定的,所以在内存使用方面比较容易控制。可以提前预估队列所占用的最大内存空间,避免因为无限制的元素添加导致内存溢出。例如,在一个处理有限资源请求的系统中,使用有界阻塞队列来存储请求,可以确保系统不会因为过多的请求堆积而耗尽内存。
    • 应用场景 - 资源管理和流量控制:适用于需要对资源进行严格管理和流量控制的场景。比如,在一个服务器应用中,对于客户端的连接请求,可以使用有界阻塞队列来存储请求。当队列满时,表示服务器已经达到了最大的负载能力,新的连接请求就会被阻塞等待,直到服务器处理完一些现有请求,腾出空间来接收新的请求。这样可以有效地防止服务器因为过多的连接请求而崩溃。
  3. 无界阻塞队列(Unbounded Blocking Queue)
    • 定义与容量特点:无界阻塞队列理论上没有容量限制。例如,LinkedBlockingQueue在不指定容量时,默认是无界的(实际上受限于系统的可用内存)。这意味着生产者线程可以一直向队列中添加元素,不会因为队列满而被阻塞。当然,如果不断地添加元素,最终会受到系统内存的限制。
    • 潜在的风险 - 内存溢出:由于没有明确的容量限制,在高负载的情况下,如果生产者的速度远远快于消费者的速度,队列中的元素会不断增加,可能会导致内存溢出。例如,在一个数据采集系统中,如果采集的数据放入无界阻塞队列,但数据处理的速度跟不上采集速度,随着时间的推移,队列会占用大量的内存,最终可能使系统崩溃。
    • 应用场景 - 灵活的异步处理:适用于对任务处理的时效性要求不是特别高,并且对生产者的生产速度没有严格限制的场景。比如,在一个日志收集系统中,日志信息可以先放入无界阻塞队列,然后由另一个线程慢慢处理。只要系统的内存足够,就可以保证日志信息不会丢失,并且可以灵活地处理不同速率的日志生产和处理。

十、concurrentHashMap底层具体实现知道吗? 实现原理是什么? 

  1. 数据结构基础

    • JDK 1.7 及之前的实现:在 JDK 1.7 中,ConcurrentHashMap采用了分段锁(Segment)的机制。它由多个Segment数组组成,每个Segment相当于一个小型的HashMap,并且都有自己独立的锁。Segment数组的大小默认是 16,可以在初始化时通过参数进行设置。这种设计的好处是,在多线程访问时,不同Segment之间的操作可以并发进行,只有在访问同一个Segment中的元素时才需要获取锁,从而提高了并发性能。
    • JDK 1.8 及之后的实现:JDK 1.8 对ConcurrentHashMap进行了优化,摒弃了分段锁的设计,采用了数组 + 链表 + 红黑树(与HashMap类似)的结构。它的底层数据结构主要是一个Node数组,当发生哈希冲突时,会以链表的形式存储元素。当链表的长度达到一定阈值(默认为 8)时,会将链表转换为红黑树,以提高查找效率。
  2. 并发控制机制

    • JDK 1.7 的分段锁机制
      • 每个Segment都继承自ReentrantLock,这使得每个Segment都有自己的锁。例如,当一个线程要对ConcurrentHashMap中的某个元素进行操作时,它首先会确定这个元素所在的Segment,然后尝试获取该Segment的锁。如果锁被其他线程占用,那么这个线程就会被阻塞,直到获取到锁为止。这样,多个线程可以同时对不同Segment中的元素进行操作,大大提高了并发性能。
      • 对于读操作,如果不涉及到对数据结构的修改,比如get操作,是可以并发进行的,不需要获取锁。这是因为Segment内部的元素存储结构(HashEntry数组)是使用volatile关键字修饰的,保证了变量的可见性,使得不同线程在读取数据时能够看到最新的值。
    • JDK 1.8 的锁机制(CAS 和synchronized
      • 在 JDK 1.8 中,ConcurrentHashMap使用了更加细粒度的锁机制。对于putremove等可能会修改数据结构的操作,采用了乐观锁(CAS,Compare - and - Swap)和悲观锁(synchronized)相结合的方式。例如,在插入新元素时,会先使用 CAS 操作来尝试插入,如果 CAS 操作成功,说明没有其他线程同时插入相同位置的元素,操作完成;如果 CAS 操作失败,说明可能有冲突,此时会采用synchronized锁来保证操作的原子性。
      • 对于读操作,get方法同样不需要获取锁,因为Node数组和Node节点中的一些关键属性(如next指针等)都使用了volatile关键字修饰,保证了数据的可见性,使得读操作可以高效地并发进行。
  3. 具体操作的实现原理

    • put操作(JDK 1.8)
      • 首先,通过计算键的哈希值来确定元素应该插入到数组中的哪个位置。如果该位置为空,就使用 CAS 操作尝试直接插入新节点。如果 CAS 操作成功,插入完成;如果失败,说明可能有其他线程同时在插入或者修改这个位置的元素,此时会对这个位置加synchronized锁,然后再次检查这个位置的情况,进行链表或者红黑树的插入操作。
      • 如果插入后链表的长度超过了阈值(默认为 8),并且数组的长度也满足一定条件,就会将链表转换为红黑树,以提高后续操作的效率。
    • get操作(JDK 1.8)
      • 同样先计算键的哈希值,确定元素可能存储的位置。然后从数组中获取对应的节点,由于节点的关键属性是volatile的,所以可以获取到最新的值。如果这个节点就是要找的元素,直接返回;如果是链表,就沿着链表遍历查找;如果是红黑树,就按照红黑树的查找方法进行查找。
    • remove操作(JDK 1.8)
      • 先计算哈希值确定位置,然后使用 CAS 操作或者synchronized锁来保证操作的原子性,从链表或者红黑树中删除指定的元素。删除后,如果链表长度小于一定阈值,并且当前是红黑树结构,还会将红黑树转换回链表。

十一、能谈一下CAS 机制吗? 

  1. CAS(Compare - and - Swap)基本概念

    • 定义:CAS 是一种乐观锁机制,用于在多线程环境下实现对共享变量的原子操作。它包含三个操作数:内存位置(V)、旧的预期值(A)和新值(B)。CAS 操作的逻辑是,首先检查内存位置 V 的值是否等于预期值 A,如果相等,则将 V 的值更新为新值 B;如果不相等,则说明其他线程已经修改了这个值,当前操作就不执行更新操作。整个过程是原子性的,也就是不可分割的,要么全部执行成功,要么全部不执行。
    • 原子性保证方式:在硬件层面,许多现代处理器都直接支持 CAS 指令,例如,在 Intel 处理器中,有CMPXCHG指令来实现 CAS 操作。这些指令在执行时会锁住内存总线或者使用缓存一致性协议(如 MESI 协议)来保证操作的原子性,使得在多线程环境下能够安全地比较和交换数据。
  2. CAS 在 Java 中的应用场景

    • Atomic类系列:在 Java 的java.util.concurrent.atomic包中,许多原子类(如AtomicIntegerAtomicLong等)都使用了 CAS 机制。以AtomicInteger为例,它提供了诸如getAndIncrement(先获取当前值,然后将其加 1)、compareAndSet(比较并设置)等方法。当调用getAndIncrement方法时,内部就是通过 CAS 机制来实现原子操作的。例如,它会先获取当前AtomicInteger对象的值作为旧的预期值 A,将这个值加 1 作为新值 B,然后通过 CAS 操作来更新内存中的值。如果多个线程同时调用这个方法,只有一个线程能够成功更新,其他线程会发现预期值和内存中的实际值不匹配,从而重新尝试操作。
    • 非阻塞算法实现:CAS 机制被广泛用于实现非阻塞算法。非阻塞算法是指在多线程环境下,一个线程的失败或暂停不会导致其他线程的阻塞。例如,在一个简单的无锁队列(Lock - Free Queue)实现中,可以使用 CAS 来保证入队和出队操作的原子性。在入队操作时,通过 CAS 操作来尝试将新元素插入到队列的尾部;在出队操作时,通过 CAS 操作来尝试获取并移除队列头部的元素。这种非阻塞算法能够提高系统的并发性能,因为它避免了传统锁机制可能带来的线程阻塞和上下文切换开销。
  3. CAS 的优点和缺点

    • 优点
      • 高性能并发操作:相比传统的锁机制,CAS 在并发度较高的情况下能够提供更好的性能。因为它不需要像锁那样进行线程的阻塞和唤醒操作,减少了线程上下文切换的开销。在多核处理器环境下,多个线程可以同时尝试进行 CAS 操作,只有在发生冲突时才需要重试,这使得系统能够更充分地利用多核处理器的资源。
      • 避免死锁情况:由于 CAS 操作是基于乐观锁的思想,不存在像传统锁机制那样可能导致的死锁问题。线程在执行 CAS 操作时不会被阻塞等待锁,所以不会出现多个线程相互等待对方释放锁的情况。
    • 缺点
      • ABA 问题:这是 CAS 机制的一个典型问题。假设一个共享变量最初的值是 A,一个线程想要将其更新为 C,它首先检查变量的值是 A,然后在准备更新时,另一个线程将变量的值先改为 B,然后又改回 A。此时,第一个线程会认为变量的值没有变化,仍然按照预期将其更新为 C,但实际上变量已经被其他线程修改过了。在一些对数据变化过程敏感的场景中,ABA 问题可能会导致错误的结果。为了解决 ABA 问题,可以使用带有版本号的原子引用(如AtomicStampedReference),每次数据变化时,版本号也随之更新,这样在进行 CAS 操作时,不仅比较数据的值,还比较版本号,从而避免 ABA 问题。
      • 循环开销问题:在高并发情况下,如果多个线程频繁地竞争同一个共享变量的 CAS 操作,可能会导致大量的循环重试。例如,当多个线程同时尝试更新一个AtomicInteger的值时,不成功的线程需要不断地重试 CAS 操作,这会消耗一定的 CPU 资源,可能会降低系统的性能。在这种情况下,可能需要考虑使用其他的并发控制机制,或者对算法进行优化,以减少竞争和重试次数。

十二、volatile 关键字有什么作用? 底层实现原理是什么?

  1. volatile关键字的作用

    • 保证变量的可见性
      • 多线程环境下的可见性问题描述:在多线程环境中,如果没有适当的同步机制,一个线程对共享变量的修改可能不会被其他线程及时看到。例如,线程 A 修改了一个共享变量x的值,但是线程 B 可能由于缓存等原因,仍然使用旧的值。这是因为每个线程可能会在自己的缓存中保存变量的值,而不会立即更新到主内存或者从主内存中获取最新的值。
      • volatile如何保证可见性:当一个变量被声明为volatile时,JVM 会确保对这个变量的修改会立即被更新到主内存中,并且每次使用这个变量时,都会从主内存中重新读取。这就保证了不同线程之间对这个变量的修改是相互可见的。例如,在一个简单的多线程程序中,一个volatile变量flag用于控制线程的执行,当一个线程修改了flag的值后,其他线程能够立即察觉到这个变化并做出相应的反应。
    • 禁止指令重排序
      • 指令重排序问题描述:为了提高程序的执行效率,编译器和处理器可能会对指令进行重新排序。在单线程环境下,这种重排序不会影响程序的最终结果,因为单线程的指令执行顺序是符合 as - if - serial 语义的,即不管怎么重排序,在单线程看来,程序的执行结果和按照顺序执行是一样的。但是在多线程环境下,指令重排序可能会导致意想不到的问题。
      • volatile如何禁止指令重排序volatile关键字会在变量的读写操作前后插入内存屏障(Memory Barrier)来禁止指令重排序。内存屏障是一种硬件或软件层面的机制,它可以确保在屏障之前的指令和之后的指令的执行顺序符合特定的规则。对于volatile变量的读操作,会在它之前插入一个读屏障,保证在读取volatile变量之前,前面的所有普通变量的读操作都已经完成;对于volatile变量的写操作,会在它之后插入一个写屏障,保证在写入volatile变量之后,后面的所有普通变量的写操作都在这个volatile变量更新之后进行。
  2. volatile的底层实现原理

    • 硬件层面 - 缓存一致性协议
      • 缓存系统与一致性问题:现代计算机系统通常有多级缓存,每个处理器核心都有自己的一级缓存(L1 Cache),部分还有二级缓存(L2 Cache),多个核心可能共享三级缓存(L3 Cache)。当一个变量存储在缓存中时,不同核心对这个变量的修改可能会导致缓存数据不一致。为了解决这个问题,硬件层面采用了缓存一致性协议,如 MESI 协议(Modified、Exclusive、Shared、Invalid)。
      • volatile与缓存一致性协议的关联:当一个变量被声明为volatile时,硬件会通过缓存一致性协议来确保对这个变量的修改能够及时地在不同的缓存之间进行同步。例如,当一个核心修改了一个volatile变量的值,根据 MESI 协议,这个核心的缓存行状态会从Shared(共享)或Exclusive(独占)变为Modified(已修改),并且会通过缓存一致性协议通知其他核心,将它们缓存中对应的变量副本标记为Invalid(无效),从而保证其他核心在下次访问这个变量时会从主内存中获取最新的值。
    • JVM 层面 - 内存屏障指令
      • JVM 对volatile的处理方式:JVM 在处理volatile变量时,会在字节码层面插入适当的内存屏障指令。对于不同的硬件平台和处理器架构,JVM 会根据其支持的指令集来选择合适的内存屏障指令。例如,在 x86 架构下,lock指令可以起到内存屏障的作用。当执行volatile变量的写操作时,JVM 可能会在字节码指令序列中插入lock指令,这个指令会将处理器的缓存写入内存,并通过缓存一致性协议来确保其他处理器的缓存同步。对于volatile变量的读操作,也会插入相应的指令来保证从主内存中读取最新的值,而不是使用缓存中的旧值。

十三、讲一下wait notify 为什么要在synchronized 代码块中?

  1. waitnotifysynchronized的基本概念

    • synchronized关键字:用于实现多线程之间的同步。它保证在同一时刻,只有一个线程能够访问被synchronized修饰的代码块或方法。当一个线程进入synchronized代码块或方法时,它会获取对象的锁,其他试图进入的线程会被阻塞,直到锁被释放。这种机制主要用于保护共享资源,避免多个线程同时访问和修改共享资源导致的数据不一致性。
    • wait方法:是Object类中的一个方法,用于使当前线程进入等待状态,并且会释放当前线程持有的对象锁。线程会一直等待,直到其他线程调用同一个对象的notifynotifyAll方法来唤醒它。wait方法通常用于实现线程之间的协作,比如生产者 - 消费者模式中,消费者线程发现没有资源可消费时,就可以调用wait方法等待生产者生产资源。
    • notify方法:也是Object类中的一个方法,用于唤醒在同一个对象上等待的一个线程(如果有多个线程等待,则随机唤醒一个)。notify方法需要在持有对象锁的情况下调用,它的作用是通知那些因为调用wait方法而等待的线程可以尝试重新获取锁并继续执行。
  2. 为什么waitnotify要在synchronized代码块中使用

    • 确保线程安全和正确的锁操作
      • 锁的持有与释放一致性wait方法会释放当前线程持有的对象锁,而notify方法需要在持有对象锁的情况下调用。如果没有synchronized块来保证锁的正确获取和释放,可能会导致锁的混乱。例如,假设没有synchronized,一个线程在没有获取锁的情况下调用wait,可能会导致异常或者不符合预期的行为,因为没有办法正确地释放锁。同样,在没有锁的情况下调用notify,可能会在没有正确同步的情况下唤醒其他线程,这些线程在恢复执行时可能会因为没有正确的锁状态而出现并发问题。
      • 避免竞争条件和数据不一致性:在多线程环境下,如果多个线程同时访问和操作共享资源,并且没有正确的同步机制,就会出现竞争条件。例如,在一个生产者 - 消费者场景中,假设有一个共享的缓冲区,生产者向缓冲区中添加产品,消费者从缓冲区中获取产品。如果waitnotify不在synchronized代码块中使用,可能会出现消费者在缓冲区为空时没有等待,或者生产者在缓冲区已满时仍然添加产品等情况,导致数据不一致和程序错误。
    • 实现正确的线程协作逻辑
      • 基于锁的等待 - 唤醒机制synchronized块提供了一个基于锁的同步环境,使得waitnotify能够按照正确的顺序和逻辑来操作。在这个环境中,线程可以安全地等待和被唤醒。例如,在一个线程等待某个条件满足(如缓冲区有产品可供消费)时,它可以在synchronized块中调用wait方法,释放锁,让其他线程(如生产者)能够访问共享资源(缓冲区)。当生产者向缓冲区添加产品后,通过notify方法唤醒等待的消费者线程,消费者线程在被唤醒后,重新获取锁,然后检查条件是否满足(如缓冲区是否有产品),如果满足则进行消费操作。这种基于锁的等待 - 唤醒机制只有在synchronized代码块的环境下才能正确地实现,以确保线程之间的协作是安全和有效的。

十四、ArrayBlockingQueue实现原理

  1. 数据结构基础

    • 底层数据存储ArrayBlockingQueue是一个有界阻塞队列,其底层是通过数组来实现存储元素的功能。这个数组在队列初始化时就被创建,并且其大小是固定的,这就是所谓的 “有界” 特性。例如,当创建一个容量为nArrayBlockingQueue时,内部会创建一个长度为n的数组来存储元素。
    • 数组索引的使用:在队列操作中,通过对数组索引的操作来实现入队和出队功能。队列通常有一个头指针(head)和一个尾指针(tail)。头指针指向队列头部元素(即将要被取出的元素),尾指针指向队列尾部元素(下一个将要插入元素的位置)。在入队操作时,新元素被插入到尾指针所指向的位置,然后尾指针向后移动(通常是通过取模运算来实现循环数组的效果,以应对尾指针到达数组末尾的情况);在出队操作时,头指针所指向的元素被取出,然后头指针向后移动。
  2. 并发控制机制

    • 锁的使用ArrayBlockingQueue内部使用了ReentrantLock来实现并发控制。ReentrantLock是一个可重入锁,它提供了与synchronized类似的同步功能,但具有更高的灵活性。在队列操作中,所有对共享数据(数组和指针等)的访问都需要获取这个锁,以确保在同一时刻只有一个线程能够进行入队或出队操作,从而避免数据不一致的情况。
    • 条件变量(Condition)的配合:除了锁之外,ArrayBlockingQueue还使用了Condition对象来实现阻塞和唤醒机制。Condition对象通常与ReentrantLock配合使用,用于实现更精细的线程间通信。在ArrayBlockingQueue中有两个Condition对象,一个用于控制入队操作(notFull),另一个用于控制出队操作(notEmpty)。当队列满时,执行入队操作的线程会在notFull条件上等待;当队列空时,执行出队操作的线程会在notEmpty条件上等待。当有元素入队或出队时,相应的条件变量会被唤醒,使得等待的线程能够继续执行。
  3. 主要操作的实现原理

    • 入队操作(put方法)
      • 当一个线程调用put方法向队列中添加元素时,首先会获取锁。然后检查队列是否已满,如果已满,则调用notFull.await()方法,释放锁并使线程进入等待状态,直到有空间可以插入元素。如果队列未满,则将元素插入到尾指针所指向的位置,然后更新尾指针(通过取模运算保证指针在数组范围内循环)。最后,检查如果队列在插入元素之前是空的,说明有线程可能在notEmpty条件上等待出队操作,所以调用notEmpty.signal()方法唤醒等待出队的线程,最后释放锁。
    • 出队操作(take方法)
      • 当一个线程调用take方法从队列中取出元素时,同样先获取锁。然后检查队列是否为空,如果为空,则调用notEmpty.await()方法,释放锁并使线程进入等待状态,直到队列中有元素可以取出。如果队列不为空,则取出头指针所指向的元素,更新头指针(通过取模运算保证指针在数组范围内循环)。接着,检查如果队列在取出元素之前是满的,说明有线程可能在notFull条件上等待入队操作,所以调用notFull.signal()方法唤醒等待入队的线程,最后释放锁。

十五、怎么理解线程安全? 

线程安全是指在多线程环境下,程序或数据结构能够正确地运行,并且能够保证数据的一致性、完整性以及预期的行为,不会因为多个线程的并发访问和操作而出现错误或不可预测的结果。可以从以下几个关键方面来深入理解线程安全:

数据一致性

  • 共享数据的保护:在多线程程序中,多个线程可能会同时访问和操作一些共享的数据资源,比如全局变量、静态变量、共享对象等。线程安全要求在这种并发访问的情况下,这些共享数据始终保持一致的状态。例如,有一个全局计数器变量,多个线程都可能对其进行递增操作。如果没有采取适当的线程安全措施,可能会出现一个线程读取到计数器的值后,还未来得及更新,另一个线程就又读取了相同的旧值并进行递增,导致最终计数器的值增加的次数不符合预期,数据出现不一致。

原子操作

  • 不可分割的操作单元:原子操作是指在执行过程中不会被其他线程中断的操作,它要么全部执行成功,要么全部不执行,就好像是一个不可分割的整体。例如,在 Java 中,一些基本数据类型的简单赋值操作(如int x = 5;)通常是原子操作,但像复合操作(如++x,先读取x的值,然后加 1,再赋值回去)在多线程环境下就不一定是原子操作了。对于涉及共享数据的复合操作,如果要保证线程安全,就需要将其转化为原子操作,比如使用synchronized关键字或者原子类(如AtomicInteger等)来实现。

可见性

  • 数据更新的及时可见:在多线程环境下,每个线程可能会有自己的缓存(如处理器的缓存),当一个线程修改了共享数据后,其他线程需要能够及时看到这个修改。如果没有相应的机制保证,可能会出现一个线程已经修改了数据,但其他线程仍然使用旧的数据进行后续操作的情况。例如,线程 A 修改了一个共享变量的值,由于没有保证可见性,线程 B 可能因为从自己的缓存中获取数据而没有察觉到这个变化,继续按照旧值进行操作,从而导致错误。在 Java 中,可以通过使用volatile关键字等方式来保证数据的可见性。

顺序性

  • 指令执行的正确顺序:为了提高程序的执行效率,编译器和处理器可能会对指令进行重新排序。在单线程环境下,这种重排序通常不会影响程序的最终结果,因为单线程遵循 as - if - serial 语义,即无论怎么重排序,看起来程序都是按照顺序执行的。但在多线程环境下,指令重排序可能会导致问题。例如,线程 A 的某些指令重排序后,可能会影响到线程 B 对共享数据的访问和操作顺序,导致结果错误。保证线程安全需要确保在多线程环境下指令执行的顺序符合预期,比如通过使用synchronized关键字或者内存屏障(如volatile关键字所涉及的内存屏障)等来防止指令重排序。

并发访问控制

  • 避免并发冲突:通过使用合适的并发控制机制来限制多个线程对共享资源的访问方式,从而避免并发冲突。常见的并发控制机制有锁(如synchronized关键字、ReentrantLock等)、原子类、并发容器(如ConcurrentHashMap等)等。这些机制可以确保在同一时刻只有特定的线程能够访问共享资源,或者以一种安全的方式实现对共享资源的并发操作。例如,使用synchronized关键字修饰一个方法,那么在同一时刻只有一个线程能够进入该方法并访问其中的共享资源,其他线程需要等待该线程释放锁后才能进入。

总之,线程安全就是要在多线程环境下,综合考虑数据一致性、原子操作、可见性、顺序性以及并发访问控制等方面,确保程序能够正确、稳定地运行,避免因多线程并发操作而带来的各种问题。

十六、什么是可重入,什么是可重入锁?它用来解决什么问题? 实现原理

  1. 可重入的概念

    • 定义:可重入是指一个程序或子程序可以 “重新进入”,即在执行过程中可以再次调用自身而不会出现错误。在多线程环境下的锁机制中,如果一个线程已经获取了某个锁,当它再次尝试获取这个锁时(例如在递归调用或者嵌套调用同一个被锁保护的方法时),如果能够成功获取锁而不会被阻塞,那么这个锁就是可重入的。
    • 示例:假设在一个方法methodA中获取了一个锁,在methodA内部又调用了另一个方法methodB,而methodB也需要获取相同的锁。如果这个锁是可重入的,那么在同一个线程中,methodB可以成功获取锁并继续执行;如果不是可重入的,methodB获取锁时会被阻塞,导致死锁,因为当前线程已经持有这个锁,无法再次获取。
  2. 可重入锁的概念及使用场景

    • 定义:可重入锁是一种支持可重入特性的锁。在 Java 中,ReentrantLock和被synchronized关键字修饰的代码块或方法所对应的锁都是可重入锁。可重入锁主要用于在多线程环境下,当一个方法可能会递归调用自身或者在嵌套的方法调用中需要多次获取同一把锁时,保证线程能够正常地获取锁并执行,避免死锁的发生。
    • 示例场景
      • 递归调用场景:考虑一个计算阶乘的方法,为了保证在多线程环境下数据的正确性,使用可重入锁来保护共享变量。假设factorial方法用于计算阶乘,并且有一个共享的变量用于记录计算结果。

    import java.util.concurrent.locks.ReentrantLock;

    class FactorialCalculator { private ReentrantLock lock = new ReentrantLock(); private int result = 1;

    public int factorial(int n) {
        lock.lock();
        try {
            if (n > 1) {
                result *= n;
                return factorial(n - 1);
            } else {
                return result;
            }
        } finally {
            lock.unlock();
        }
    }
    

    }

在这个例子中,factorial方法在递归调用自身时,由于ReentrantLock是可重入锁,所以在递归调用过程中能够多次获取锁,而不会出现锁获取失败导致的问题。

  • 嵌套方法调用场景:假设有一个类,其中包含两个方法methodAmethodB,这两个方法都需要对同一个共享资源进行操作,并且都使用了同一个可重入锁进行保护。

    import java.util.concurrent.locks.ReentrantLock;

    class NestedMethodExample { private ReentrantLock lock = new ReentrantLock();

    public void methodA() {
        lock.lock();
        try {
            System.out.println("执行methodA");
            methodB();
        } finally {
            lock.unlock();
        }
    }
    
    public void methodB() {
        lock.lock();
        try {
            System.out.println("执行methodB");
        } finally {
            lock.unlock();
        }
    }
    

    }

在这个例子中,当methodA调用methodB时,由于使用的是可重入锁,methodB可以成功获取锁并执行,不会因为锁已经被methodA获取而导致线程被阻塞。

可重入锁的实现原理

  • 计数机制:可重入锁内部通常会维护一个计数器来记录锁被同一个线程获取的次数。当一个线程第一次获取锁时,计数器初始化为 1。每次同一个线程再次获取这个锁时,计数器就会加 1。当线程释放锁时,计数器减 1,当计数器减到 0 时,表示锁已经完全释放,其他线程可以获取这个锁。
  • 线程标识记录:可重入锁还会记录获取锁的线程标识。在获取锁时,锁会检查当前请求获取锁的线程是否是已经持有锁的线程。如果是,就允许获取锁(并增加计数器的值);如果不是,就按照正常的锁获取规则(可能会被阻塞)来处理。例如,在ReentrantLock的实现中,通过AbstractQueuedSynchronizer(AQS)来管理锁的获取和释放,AQS 内部会记录获取锁的线程信息和获取次数等相关数据,以实现可重入的特性。