JAVA面试:线程篇

Thread的生命周期

指线程从创建到销毁的整个过程。在线程的生命周期中,可能会经历不同的状态变化。

线程的运行状态:

  • NEW:线程对象被创建,未启动线程

  • READY:start()启动

  • RUNNABLE(可以运行的线程状态):线程已被加载到线程调度器的就绪队列中,等待CPU的调度执行。

  • RUNNING:线程正在执行,执行过程中可能会因为线程间的切换、等待某个条件满足等原因进入阻塞状态。

  • BLOCKED:线程被阻塞,因为等待某种资源(如等待I/O操作完成、锁竞争如:sync)

  • Dead:线程执行完毕或者因异常而终止。

ThreadLocal了解吗

  • ThreadLocal提供线程局部变量。在多线程环境下,每个线程都拥有自己的 ThreadLocal 变量副本,各线程之间的变量互不影响,这样可以有效避免数据同步问题,提高程序效率。
  • 基本原理:每个 Thread 都有一个 ThreadLocalMap 类型的成员变量,用于存储当前线程的 ThreadLocal 值,
    ThreadLocalMap 的键是 ThreadLocal 对象,而值是我们真正存储的数据,通常需要子类重写initialValue()方法以提供初始值。
  • hreadLocalMap键是弱引用,这意味着当没有强引用指向 ThreadLocal 对象时,它会被垃圾回收器回收。如果 ThreadLocal 被回收了,而其对应的值还在 ThreadLocalMap 中,则会出现内存泄漏。因此,在使用完 ThreadLocal 后,最好手动调用 remove() 方法清理。
  • ThreadLocal 的使用场景主要是在多线程环境下需要保持线程安全的数据隔离。

使用场景:

  1. 日志跟踪:在分布式系统中,可以使用 ThreadLocal 来存储请求的唯一标识,比如请求 ID。这样,在不同的线程中都可以方便地获取到请求 ID,方便进行日志跟踪和排查问题。
  2. 用户上下文:在 Web 应用程序中,可以使用 ThreadLocal 存储用户的上下文信息,比如用户的登录状态、权限等。这样,在不同的线程中都可以方便地获取到用户的上下文信息,避免频繁地传递参数。
    RequestContextHolder中ThreadLocal 使用ThreadLocal 保存请求属性值,(token等认证信息)
  3. 线程池:在使用线程池的情况下,如果需要在线程之间传递一些上下文信息或者状态,可以使用 ThreadLocal 来存储这些信息。比如,在 Web 应用程序中,可以将用户信息存储在 ThreadLocal 中,以便在不同的线程中访问。

线程池的意义

  • 线程池是 Java 中的一个重要线程管理工具,它可以有效地管理多个线程,提高程序的性能。
  • 线程池的意义和作用如下:
  • 提高程序性能:线程池可以有效地减少线程创建和销毁的开销,提高程序的性能。
  • 降低资源消耗:线程池可以有效地控制线程数量,避免创建过多的线程导致资源消耗过大。
  • 提高线程的可管理性:线程池可以对线程进行统一管理,方便进行线程的调度和控制。
  • 实现线程的复用:线程池可以将线程进行复用,避免频繁创建和销毁线程。

线程池原理、线程池由什么组成

  • 线程池是一种多线程处理形式,它通过维护一组可复用的线程资源来高效地执行多个任务。线程池的核心原理在于减少线程创建和销毁的开销,同时通过合理的调度策略来管理和控制并发执行的任务队列,以提高系统整体性能。

线程池管理器(ThreadPool Manager):

  • 负责创建和销毁线程池。
  • 控制线程池的大小,即线程池中工作线程的最大数量。
  • 提供添加新任务、拒绝任务、调整线程池参数等操作。

工作线程(Worker Threads 或 Pool Workers):

  • 线程池中的线程实体,它们是真正的任务执行者。
  • 当没有任务时,工作线程会处于等待状态或者循环检查任务队列。
  • 当有新的任务到达时,线程池管理器会选择一个空闲的工作线程来执行任务。

任务接口(Task Interface):

  • 任务通常需要实现特定的接口(如 Java 中的 Runnable 或 Callable 接口),以便可以被工作线程调度执行。
  • 任务接口定义了任务的行为,包括任务的具体执行逻辑以及可能的返回值(对于 Callable 接口而言)。

任务队列(Task Queue 或 Work Queue):

  • 一种线程安全的数据结构(如 Java 中的 BlockingQueue),用于存储待执行的任务。
  • 工作线程从该队列中取出任务进行执行,当队列为空时,工作线程可以采取不同的策略,如等待、休眠或终止。

线程工厂(Thread Factory)(可选组成部分):

  • 用于创建新线程实例的工厂类,可以根据需要定制线程的属性,比如设置名称、优先级等。

拒绝策略(Rejected Execution Handler):

  • 当线程池饱和(例如:所有工作线程都在忙碌,并且任务队列已满)时,拒绝策略将决定如何处理无法放入队列的新提交的任务,常见的拒绝策略包括直接丢弃、抛出异常、调用自定义回调函数等。

线程池7个参数、线程池执行流程

线程池的7个参数如下:

  • corePoolSize:核心线程池大小。核心线程池中的线程会一直存活,即使没有任务需要执行。
  • maximumPoolSize:最大线程池大小。最大线程池大小是线程池可以创建的最大线程数。
  • keepAliveTime:线程空闲时间。线程空闲时间超过 keepAliveTime 时,线程会被销毁。
  • unit:keepAliveTime 的单位。
  • workQueue:任务队列。线程池会将任务放入任务队列中,等待线程执行。
  • threadFactory:线程工厂。线程池会使用线程工厂创建线程。
  • handler:饱和策略。当任务队列满了,并且线程池中的线程都处于工作状态时,线程池会使用饱和策略来处理新提交的任务。

线程池的执行流程如下:

  1. 当有任务提交到线程池时,线程池会先检查核心线程池是否有空闲线程。如果有空闲线程,则会直接将任务分配给空闲线程执行。
  2. 如果核心线程池中没有空闲线程,则会检查线程池的最大线程池大小。如果线程池的最大线程池大小没有达到,则会创建一个新的线程来执行任务。
  3. 如果线程池的最大线程池大小已经达到,则会将任务放入任务队列中。
  4. 如果任务队列满了,则会根据饱和策略来处理新提交的任务。常见的饱和策略包括抛出异常、丢弃任务、阻塞任务提交线程或者执行调用者的线程来执行任务。

线程池、核心线程数、最大线程数怎么设置的?

  • 线程池按业务进行分类、执行
  1. CPU密集型:

    • 核心线程数不超过 CPU 核心数
    • 最大线程数 CPU 核心数 + 1
  2. IO密集型:

    • 核心线程数量 CPU 核心数 2 倍
    • 最大线程数量 CPU 核心数 2 倍 + 1

介绍一下知道有哪些阻塞队列

  1. ArrayBlockingQueue:
    • 它是一个由数组支持的有界FIFO(先进先出)队列。
    • 创建时需要指定容量大小,并且这个容量是固定的,无法扩容。
    • 插入操作(offer、put)和移除操作(poll、take)在队列满或空时会阻塞线程,直到空间可用或者有元素可取。
  2. LinkedBlockingQueue:
    • 一个基于链表结构的阻塞队列,默认情况下可以动态扩展到Integer.MAX_VALUE,但也可以在创建时指定容量上限使其变为有界队列。
    • 同样遵循FIFO原则,但在内部实现上更灵活,适合大容量存储。
    • 具有与ArrayBlockingQueue相似的阻塞特性。
  3. PriorityBlockingQueue:
    • 这是一个无界的优先级队列,根据元素的自然排序(通过Comparable接口)或自定义Comparator排序决定元素的优先级。
    • 当队列为空时,take操作会阻塞等待;当队列非空时,总是取出并返回优先级最高的元素。
    • 注意,插入到此队列中的元素必须实现Comparable接口或者提供Comparator,否则将抛出异常。
  4. SynchronousQueue:
    • 这是一种特殊的无缓冲队列,它不存储元素,而是在生产者放入元素的同时匹配一个消费者取出元素。
    • 如果没有正在等待的消费者,则试图放入元素的操作将会阻塞,同样地,如果没有可用的元素,那么消费者尝试获取元素也会被阻塞。
    • SynchronousQueue通常用于传递任务而不是存储任务,它可以强制执行“工作窃取”策略,即每个插入的任务都会立即交给另一个线程处理。
  5. DelayQueue:
    • 这是一个无界队列,其中包含实现了Delayed接口的对象,只有当对象的延迟时间到期后才能从队列中获取到。
    • 能够按照延迟时间顺序进行处理,例如定时任务调度器。
  6. LinkedTransferQueue:
    • 基于链表的无界并发队列,除了基础的插入和删除功能外,还提供了transfer方法,该方法可以使生产者线程直接将元素传输给消费者线程,如果当前没有消费者等待,则生产者线程会被阻塞。

常见的阻塞队列有哪些、自己有实现过吗

实现 BlockingQueue 接口可以提供以下能力:

  1. 阻塞生产者线程:当队列已满时, add() 方法会阻塞生产者线程,直到队列有空闲位置。
  2. 阻塞消费者线程:当队列为空时, remove() 方法会阻塞消费者线程,直到队列有元素可供消费。
  3. 非阻塞添加和删除: offer() 方法可以向队列中添加元素,如果队列已满则返回 false ,不会阻塞生产者线程。 poll() 方法可以从队列中删除元素,如果队列为空则返回 null ,不会阻塞消费者线程。
  4. 获取队列大小: size() 方法可以获取队列中当前的元素数量。
  5. 判断队列是否为空: isEmpty() 方法可以判断队列是否为空。
  6. 判断元素是否存在: contains() 方法可以判断队列中是否存在指定元素。
  7. 遍历队列: iterator() 方法可以获取队列的迭代器,用于遍历队列中的元素。
  8. 清空队列: clear() 方法可以清空队列中的所有元素。

阻塞队列的使用场景如下:

  1. 生产者-消费者模型:阻塞队列常用于实现生产者-消费者模型,生产者向队列中添加任务,消费者从队列中获取任务并执行。阻塞队列可以确保生产者和消费者之间的协调和平衡。
  2. 线程池:线程池中的任务队列通常使用阻塞队列来存储待执行的任务。当线程池中的线程都在执行任务时,新提交的任务会被阻塞,直到有空闲线程可用。
  3. 消息传递:阻塞队列可以用于不同线程之间的消息传递。一个线程将消息放入队列,另一个线程从队列中获取消息进行处理。阻塞队列可以确保线程间的同步和顺序执行。
  4. 并发编程:在并发编程中,阻塞队列可以用于线程间的同步和通信。多个线程可以通过阻塞队列进行数据交换和协作。
  • 总之, BlockingQueue 接口的实现提供了一种线程安全的、具有阻塞能力的队列,适用于多线程环境下的任务调度、消息传递和并发编程等场景。

对BlockQueue的理解

  • 阻塞队列可以用于实现生产者-消费者模型,提供线程安全的数据传递和协调机制。

主要特点:

  1. 阻塞操作:当队列已满时,生产者线程尝试向队列中添加元素时会被阻塞,直到队列有空闲位置。当队列为空时,消费者线程尝试从队列中获取元素时会被阻塞,直到队列有可用元素。
  2. 线程安全:阻塞队列提供了线程安全的操作,多个线程可以同时操作队列而不会导致数据不一致或竞态条件。

ArrayBlockQueue与LinkedBolckQueue区别,底层怎么实现的

  1. 数据结构不同:
    • ArrayBlockingQueue 底层采用固定大小的数组来存储元素。插入和移除元素时,由于数组的特性,需要通过循环数组的方式进行操作。当队列满或空时,等待的线程会阻塞。
    • LinkedBlockingQueue 底层使用链表(通常是非循环双向链表)来存储元素。插入和移除元素时,链表可以动态增长和收缩,不需要预先知道队列的大小。
  2. 容量限制:
    • ArrayBlockingQueue 在创建时必须指定容量,并且一旦创建后,容量不可改变。
    • LinkedBlockingQueue 可以选择在创建时指定容量,如果不指定,则默认为 Integer.MAX_VALUE,即理论上可以无界地增长,但在实际应用中,考虑到内存限制和性能问题,不建议将其作为无界队列使用。
  3. 锁机制:
    • ArrayBlockingQueue 使用一个可重入锁(ReentrantLock)来控制对队列的并发访问。生产和消费共用同一个锁。
    • LinkedBlockingQueue 采用了分离锁策略,有两个锁对象:一个是用于插入(put)操作的 putLock,另一个是用于移除(take)操作的 takeLock,这样在多线程环境下能提供更高的并发性,因为生产者和消费者可以独立地操作队列两端,互不影响。
  4. 性能差异:
    • ArrayBlockingQueue 因为基于数组,所以插入、删除操作在队列头部和尾部相对快速,但扩容不可行,因此如果频繁出现边界情况,可能会影响性能。
    • LinkedBlockingQueue 的插入和删除操作涉及节点的创建与链接,可能会有轻微的额外开销,但是在大多数情况下,尤其是高并发场景下,由于其更细粒度的锁控制,可以获得更好的性能表现。

介绍CAS、解析cas底层原理

  • CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于实现多线程环境下的线程安全。
  • CAS操作是一种乐观锁的实现方式,它通过比较预期值和实际值来判断是否存在竞争条件,从而避免了传统锁的开销。
  • Unsafe类提供了一些方法,可以直接进行CAS操作。
  • concurrnet.atomic包下AtomicLong等实现CAS乐观锁

ABA问题

  • ABA问题是指在并发环境下,一个值从A变为B,然后再变回A的过程,而某些并发操作可能会错误地认为该值没有发生变化。这种情况可能导致并发操作的逻辑错误。

  • AtomicStampedReference通过增加expectedStamp、newStamp标记值(版本号)判断是否需要将期望的引用值更新为新引用

介绍一下ReentrantLock实现原理

  • ReentrantLock是Java并发包(java.util.concurrent.locks)中的可重入锁,其主要基于AbstractQueuedSynchronizer(AQS)实现。以下是ReentrantLock的核心实现原理:

同步状态:

  • ReentrantLock内部维护一个volatile的整型变量作为同步状态,表示锁被重入的次数。当线程获取锁时,同步状态加1;释放锁时,同步状态减1。

可重入性:

  • 可重入意味着同一个线程可以对已被它持有的锁进行多次锁定,每次锁定都会增加同步状态计数。只有当线程退出所有同步代码块且同步状态减至0时,锁才会真正被释放。

公平与非公平:

  • ReentrantLock提供了公平锁和非公平锁两种模式:
  • 公平锁:在锁释放后,会优先唤醒等待时间最长的线程来获取锁。
  • 非公平锁:尝试获取锁时不保证先来先服务原则,新来的线程有可能直接抢占锁,这样可以减少上下文切换开销,但可能导致某些线程饥饿。

AQS框架组成:

  1. AQS结构:
    • AQS使用一个FIFO的双向队列来管理等待获取锁的线程,每个节点是一个Node对象,包含了线程引用、前驱节点、后继节点、节点在队列状态
    • AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。
  2. 锁的模式
    • 独占模式
    • 共享模式
  3. State状态流转、实现锁可重入性
    • State初始化的时候为0,表示没有任何线程持有锁。
    • 当有线程持有该锁时,值就会在原来的基础上+1,同一个线程多次获得锁是,就会多次+1,这里就是可重入的概念。
    • 解锁也是对这个字段-1,一直到0,此线程对锁释放。
  4. 可重入性的应用
    • ReentrantLock:使用AQS保存锁重复持有的次数。当一个线程获取锁时,ReentrantLock记录当前获得锁的线程标识,用于检测是否重复获取,及错误线程试图解锁操作时异常情况的处理。
    • Semaphore:使用AQS同步状态来保存信号量的当前计数。tryRelease会增加计数,acquireShared会减少计数。
    • CountDownLatch:使用AQS同步状态来表示计数。计数为0时,所有的Acquire操作(CountDownLatch的await方法)才可以通过。
    • ReentrantReadWriteLock:使用AQS同步状态中的16位保存写锁持有的次数,剩下的16位用于保存读锁的持有次数。
    • ThreadPoolExecutor: Worker利用AQS同步状态实现对独占线程变量的设置(tryAcquire和tryRelease)。

获取锁过程:

  • 当线程尝试获取锁时,首先通过CAS操作更新同步状态,成功则获取锁并返回;失败则将当前线程包装成一个Node节点加入到同步队列中,并阻塞当前线程。
    对于非公平锁,在尝试获取锁之前可能还会有一次非公平的直接获取锁的操作。

释放锁过程:

  • 释放锁时,同步状态递减,若递减后同步状态为0,则需要从等待队列中唤醒一个线程(对于公平锁,唤醒的是队列头节点即等待时间最长的线程;对于非公平锁,唤醒机制相对更复杂)。

Condition条件队列:

  • ReentrantLock还支持多个Condition对象,每个Condition都关联一个条件等待队列,用于线程间的协作通信。当调用Condition对象的await方法时,线程将释放锁并进入条件等待队列,调用signal或signalAll方法时,相应条件满足的线程将从等待队列转移到同步队列等待获取锁。
    总结起来,ReentrantLock通过AQS提供的底层机制,结合自旋+CAS操作、线程挂起与唤醒等技术,实现了高效的线程同步控制和灵活的锁策略选择。

volatile为什么不能保证原子性

  • x++;这行代码指令层面有三步且不能保证全部执行成功、全部执行失败,即原子性:
  1. 从主内存读取 x 的当前值。
  2. 将该值加一。
  3. 将更新后的值写回主内存。
    多线程执行下,可能出现 A线程读到x值但未执行更新指令,此时B线程读到x值,最后A、B线程更新的值相同,不符合预期。
  • 实现原子性,需要使用锁机制(如 synchronized、ReentrantLock 或者 Java 中的原子类 AtomicInteger),这些机制会确保在同一时间只有一个线程可以执行包含多个步骤的复合操作,从而实现了原子性。

介绍一下volatile实现原理

  • volatile 关键字通过使用内存屏障(memory barrier)来实现可见性和禁止重排序的效果。内存屏障是一种硬件或者指令级别的机制,它可以保证在某个点上的内存操作顺序或者可见性。
  • 缓存一致性协议是一种用于保证多个处理器或者多个核心之间共享数据的一致性的协议。
  • 当一个线程修改 volatile 变量时,会强制将该变量的最新值刷新到主内存中,而不仅仅是线程的本地缓存。并且通知其他处理器或者核心将对应的缓存行置为无效,这样其他处理器或者核心在读取该数据时会从内存中获取最新的值。 这样可以保证线程之间对 volatile 变量的读写操作是可见的。
  • 内存屏障确保了对volatile变量的读写操作在多线程环境下的可见性和指令有序性,解决的是处理器和编译器层面的指令重排序问题。

什么情况下使用synchronized,介绍一下synchronized实现原理

使用synchronized关键字的情况:

  • 线程安全控制:当多个线程同时访问和修改共享资源(如对象或类的实例变量)时,为防止数据不一致、竞态条件等问题,应使用synchronized来确保同一时刻只有一个线程能够执行关键代码段。
    示例:多线程环境下对一个共享计数器进行自增操作时,就需要用synchronized修饰方法或代码块,确保每次只有一个线程可以修改计数器。
  • 内存可见性:Java内存模型中规定,对于被synchronized同步的方法或者代码块,当一个线程修改了共享变量后退出同步区域,其他线程在进入同步区域时会看到这个修改后的值,即保证了内存可见性。
  • 原子性操作:虽然synchronized不能保证所有的原子操作(例如长整型、数组等复合操作),但它能确保在其作用域内的所有操作作为一个整体完成,不会被其他线程中断。

实现原理:

synchronized是Java中的内置锁机制,它的实现依赖于JVM。具体原理如下:

  • 对象监视器(Monitor):每个Java对象都有一个与之关联的对象监视器(monitor)。当线程试图获取一个由synchronized修饰的代码块或方法的锁时,它会尝试获取该对象监视器的所有权。如果监视器未被其他线程持有,则当前线程将获得监视器,并开始执行同步代码;否则,线程将被阻塞,直到监视器空闲为止。
  • monitorenter 和 monitorexit 指令:在字节码级别,JVM通过插入特定的monitorenter和monitorexit指令来实现synchronized。monitorenter对应于获取监视器,monitorexit对应于释放监视器。编译器会确保每个monitorenter都有一对monitorexit与之匹配,以保证锁的最终释放。
  • 原子性和可见性:JVM通过内存屏障(memory barrier)技术保证synchronized代码块的原子性和内存可见性。当线程进入synchronized代码块时,会执行 acquire barrier 使当前线程的工作内存与主内存保持一致;退出时,执行 release barrier 确保对共享变量的修改立即刷新到主内存,并通知其他线程。
    因此,通过synchronized关键字,我们可以有效地解决多线程环境下的并发问题,确保线程间的互斥访问和数据一致性。

synchronized与ReentrantLock的区别

  1. 获取和释放锁的方式:
    • synchronized关键字:它提供了隐式锁机制,即通过在方法或代码块上加锁来自动获得和释放锁。当线程进入同步代码块或执行同步方法时,会自动获取锁;退出同步代码块或方法时,锁会被自动释放。
    • java.util.concurrent.locks.ReentrantLock:这是一个显式锁类,使用时需要明确调用lock()方法获取锁,并在结束同步时调用unlock()方法释放锁。为了防止死锁,通常推荐在finally块中释放锁。
  2. 中断响应与可轮询性:
    • synchronized:不能响应中断,一旦一个线程获取了锁并进入了同步代码块,除非它完成或者被系统中断(如 JVM 崩溃),否则无法强制它释放锁。
    • ReentrantLock:支持可中断获取锁,即可以通过lockInterruptibly()方法尝试获取锁,该方法允许线程在等待锁的过程中响应中断请求,并且可以配合 Condition 实现等待/通知模式,从而实现更灵活的线程协作。
  3. 公平性选择:
    • synchronized:非公平锁,线程获取锁的顺序并不保证按到达的先后顺序,有可能造成“线程饥饿”现象。
    • ReentrantLock:可以根据构造函数传入参数指定是否为公平锁,默认是非公平锁,但也可以创建公平锁,公平锁会按照线程等待的顺序分配锁,减少了线程饥饿的可能性。
  4. 灵活性与扩展性:
    • synchronized:由于是关键字,功能相对简单,不提供额外的特性。
    • ReentrantLock:作为一个API级别的类,提供了更多高级功能,例如条件队列(Condition对象)、锁投票、定时锁等候等,这些都可以根据实际需求进行定制。
  5. 性能:
    • 在大多数情况下,JDK不断优化后,两者的性能差距已不大,但在某些特定场景下,比如高并发且要求公平锁时,ReentrantLock可能因为其内部实现机制而产生一定的性能开销。

数据库怎么实现乐观锁、怎么实现悲观锁

乐观锁版本号控制

  • MySQL: 在表结构设计时,为需要进行乐观锁定的记录增加一个版本号字段(例如 version 字段)。每次读取数据时,同时获取该版本号;在更新数据时,除了满足原来的更新条件外,还要加上版本号必须与之前读取到的版本号相等的条件(即 UPDATE ... WHERE version = ? AND ...)。如果因为并发导致版本号已变,则此次更新会失败,返回受影响行数为0,此时应用程序可以捕获这个异常并重新尝试操作。

悲观锁

  • 悲观锁的基本思想是在读取数据时立即对其进行锁定,阻止其他事务对同一数据进行修改,直到当前事务完成并释放锁。
  • 在MySQL(特别是InnoDB存储引擎)中,可以使用SELECT ... FOR UPDATE或LOCK IN SHARE MODE来获取悲观锁。
  1. SELECT ... FOR UPDATE: 这个语句会获取记录的排他锁(Exclusive Lock),在事务提交之前,其他试图对该记录执行更新或者删除操作的事务会被阻塞。
  2. LOCK IN SHARE MODE: 这种模式下获取的是共享锁(Shared Lock),允许其他事务读取但不允许修改,主要用于避免写-写冲突。

ReentrantLock默认是公平还是非公平,公平非公平怎么理解

  • ReentrantLock默认是非公平锁
  • 公平、非公平锁底层都依赖AbstractQueuedSynchronizer(AQS)构建,AQS内部使用了一个FIFO(先进先出)的双向链表(Node静态类)作为等待队列管理那些尝试获取但未获得锁的线程。

公平、非公平锁相同点

  1. 加锁过程中都使用CAS,调用compareAndSetState方法
  2. 使用AbstractQueuedSynchronizer(AQS)构建,AQS内部使用了一个FIFO(先进先出)的双向链表

公平、非公平锁不同点

  1. 公平锁按照请求锁的先后顺序执行加锁,确保所有等待的线程都有公平的机会获得锁,不会出现某个线程连续多次抢占锁的情况。
  2. 非公平锁有请求进行就会执行CAS抢夺锁资源(CAS 设置state状态值),不管队列中已排队的线程,未抢到时,执行公平锁流程,会再尝试一次CAS抢锁资源,失败后添加到等待队列队尾

ReentrantLock的node是什么

  • Node对象是AQS中双向链表的组成单位

ReentrantLock的第一个node为什么是一个空node

  • 初始情况下,head == null,第一个节点入队,Head会被初始化一个虚拟节点
  • 双向链表中,第一个节点为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的。
  • 当第一个节点进入队列时没有设置前置节点时,会初始化一个虚拟节点HEAD,当前线程释放锁时,会将当前线程node节点设置为HEAD节点(虚拟节点)
  • 当前节点的前置节点是HEAD节点,并且获取锁成功,头指针移动到当前node

LinkedBlockQueue用了ReentrantLock的condition,condition是什么?condition是怎么实现的?

condition是怎么实现

  • Condition 基于等待/通知模式实现,当线程调用 await() ,它会释放锁并进入等待状态。当其他线程调用 signal() 或 signalAll() ,等待的线程会被唤醒并重新竞争锁。
  • Condition 的实现使用了 LockSupport 类(unsafe)的 park() 和 unpark() 方法。 park() 方法会让当前线程进入等待状态,直到其他线程调用 unpark() 方法唤醒它。 unpark() 方法会唤醒一个等待在 LockSupport 上的线程。

LinkedBlockQueue如何使用ReentrantLock的condition

  • LinkedBlockQueue中用ReentrantLock创建Condition 对象实现 生成者-消费者模式
  • 生产者调用 put()放入任务时,先获取putLock,当放满时,调用Condition对象await(),阻塞生产者,当队列未满时,调用signal()唤醒生产者进行生产
  • 消费者调用 take()消费任务时,先获取takeLock,当队列中没有可执行任务,调用Condition对象await(),阻塞消费者,当队列有任务时,调用signal()唤醒消费者进行消费

看过JUC源码吗,讲讲CountDownLatch、CyclicBarrier、Semphore底层原理

  1. CountDownLatch
    • CountDownLatch 是一个计数器同步工具类,在初始化时设置一个计数值。线程调用 await() 方法会阻塞,直到其他线程调用了与计数值相等次数的 countDown() 方法后,所有等待在 await() 的线程才会被唤醒继续执行。其底层实现依赖于 java.util.concurrent.locks.AbstractQueuedSynchronizer(AQS),通过共享模式的锁状态来控制计数。
    • 核心方法:
      CountDownLatch(int count):构造函数,初始化计数器。
      void await():使当前线程等待,直到计数器为0。
      void countDown():递减计数器,若计数器值变为0,则释放所有等待的线程。
    • 底层原理: AQS内部维护了一个state变量作为计数器,并使用Condition实现等待/唤醒机制。当countDown()方法被调用时,尝试CAS操作减少state值;而当await()方法被调用时,如果发现state不为0,则将当前线程加入到AQS同步队列中等待,直到计数器为0时,所有的等待线程会被唤醒并从AQS队列中移除。
  2. CyclicBarrier
    CyclicBarrier 能让一组线程在某个屏障点上互相等待,直到所有线程都到达了这个屏障点,然后才允许所有线程继续执行。它的一个特点是可重用,即计数达到指定值后可以循环重置,故名“循环栅栏”。
    • 核心方法:
      CyclicBarrier(int parties, Runnable barrierAction):构造函数,初始化参与线程数及一个可选的栅栏动作。
      int await():使当前线程在栅栏处等待,直到所有线程都到达。
    • 底层原理: CyclicBarrier没有直接使用AQS的state变量进行计数,但它确实利用了ReentrantLock和Condition来实现多线程间的同步。每当一个线程到达栅栏时,它获取锁并检查是否是最后一个到达的线程。如果是最后一个,则执行barrierAction(如果有)并唤醒所有等待的线程,然后重置计数器供下一轮使用。如果不是最后一个到达的线程,则该线程会在condition上等待。
  3. Semaphore
    Semaphore 是一种信号量同步工具类,它可以用来控制同时访问特定资源的线程数量,通常用于限制并发访问的线程数。
    • 核心方法:
      Semaphore(int permits):构造函数,初始化信号量许可数。
      void acquire() 或 boolean tryAcquire():请求获取一个许可,若无可用许可则可能被阻塞或立即返回失败。
      void release():释放一个许可,允许其他线程获取许可。
    • 底层原理: Semaphore同样基于AQS,但它不是通过独占模式而是通过共享模式管理state。state代表可用的许可证数量。当一个线程调用acquire()时,AQS尝试减少state(类似于 countdown),如果state小于等于0,则线程会被加入到等待队列。反之,release()方法会增加state,从而可能唤醒等待的线程。

ConcurrentHashMap由什么组成?

  1. JDK 1.7:
    • 在Java 1.7中,ConcurrentHashMap是由Segment数组 + HashEntry数组 + 链表组成,一个Segment对应一个HashEntry数组
      它包含以下组件:
    • Segment数组:整个ConcurrentHashMap被划分为若干个Segment,每个Segment都可看作是一个独立的、线程安全的哈希表。
      Segment继承了ReentrantLock,这样就自带了锁的功能。每个Segment都有一个锁。当一个线程访问某个Segment时,其他线程只能访问其他Segment。这样,ConcurrentHashMap就可以保证线程安全,同时提高并发性能。
    • HashEntry数组:每个Segment内部维护了一个HashEntry数组,数组中的每一个元素都是一个链表的头结点,用于解决哈希冲突问题。
    • 链表:用于存储与某个键冲突的键值对,当计算出的哈希码对应的位置已经有元素存在时,新的元素会以链表的形式添加到该位置。
  2. JDK 1.8及以后版本:
    • 自Java 1.8开始,ConcurrentHashMap摒弃了Segment分段锁的设计,改为采用了一种更细粒度的CAS操作和synchronized来保证并发下的线程安全。其主要组成部分包括:
    • Node数组:类似于HashMap,ConcurrentHashMap的核心数据结构也是一个数组,数组中的每个元素是Node类型,而Node类型同样可以形成链表或红黑树(取决于元素数量是否达到阈值)。
    • CAS操作:大量使用了无锁算法如CAS(Compare and Swap)等原子操作,减少对锁的依赖,提高并发性能。
    • 红黑树:当链表长度超过一定阈值时,链表会自动转换为红黑树以优化查找效率。
    • 因此,在不同版本的Java中,虽然ConcurrentHashMap的主要目标都是提供线程安全的并发访问,但其实现细节有较大差异,从分段锁设计演变为更加精细的无锁/轻量级锁机制。
  3. 1.8版本优化了数据结构、减少锁的资源粒度。由Segment对象锁到Node节点锁

hashMap的原理,1.7和1.8的区别是什么,如何保证并发安全

  • JDK7 数组+链表
  • JDK8 数组+链表+红黑树 链表长度大于等于8 红黑树 小于等于6 链表
  • new HashMap() 时初始容量是0
  • put()的时候 开辟空间

HashMap在jdk1.8之后引入了红黑树的概念,表示若桶中链表元素超过8时,会自动转化成红黑树;

若桶中元素小于等于6时,树结构还原成链表形式。

原因:

  • 链表的时间复杂度是O(n),红黑树的时间复杂度O(logn),很显然,红黑树的复杂度是优于链表的
  • 红黑树的平均查找长度是log(n),长度为8,查找长度为log(8)=3,
  • 链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,
  • 其实在大于5时,红黑树已经优于链表的 平均查找长度
  • 树节点所占空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点。
  • 也就是说,节点少的时候,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占空间比较大,综合考虑,认为只能在节点太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树。

选择6和8的原因是:

  • 中间有个差值7可以防止链表和树之间频繁的转换。
  • 假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

如何保证并发安全

  • 多线程下使用HashMap会出现了死循环和丢失数据,导致部分线程一直运行,占用cpu时间。
    • 问题原因就是HashMap是非线程安全的,多个线程put的时候造成了某个key值Entry key List的死循环,问题就这么产生了。
      当另外一个线程get 这个Entry List 死循环的key的时候,这个get也会一直执行。最后结果是越来越多的线程死循环,最后导致服务器宕机
  • 方法上加Synchronized或者方法内部map执行操作时进行加锁
  • 强烈建议使用concurrentHashMap解决多线程并发问题

posted @ 2024-01-16 17:51  爪哇搬砖  阅读(25)  评论(0编辑  收藏  举报