java并发相关知识

一. 线程有哪几种状态

  1. 新建(New):当线程对象被创建但还没有启动时,线程处于新建状态。
  2. 就绪(Runnable):当线程被创建后,通过调用start()方法启动线程后,线程处于就绪状态。就绪状态的线程已经被加入到线程调度器的就绪队列中,等待被调度执行。
  3. 运行(Running):当线程获得了CPU时间片并开始执行时,线程处于运行状态。
  4. 阻塞(Blocked):线程在某些情况下会进入阻塞状态,如调用sleep()方法、等待I/O操作完成、试图获取锁时未成功等。在阻塞状态下的线程不会占用CPU时间,直到被唤醒或满足条件。
  5. 等待(Waiting):线程调用Object.wait()、Thread.join()或LockSupport.park()等方法时会进入等待状态,此时线程会等待其他线程的通知或特定条件的满足。
  6. 超时等待(Timed Waiting):与等待状态类似,但是可以指定等待的时间。线程调用Thread.sleep()、Object.wait(long timeout)、Thread.join(long millis)或LockSupport.parkNanos()等方法后会进入超时等待状态。
  7. 终止(Terminated):线程执行完毕或者因异常退出后进入终止状态。

二. Object.wait()、Thread.join()区别是什么

Object.wait()和Thread.join()都是用于线程间的协作,但它们的作用和用法有所不同。

  1. Object.wait():
  • Object.wait()是Object类的方法,用于让当前线程进入等待状态,并释放对象的锁。
  • 当线程调用Object.wait()方法时,它会释放对象的锁,并且进入等待状态,直到另一个线程调用相同对象的notify()或notifyAll()方法来唤醒它,或者等待超时。
  • Object.wait()通常与synchronized关键字一起使用,用于实现线程间的等待和通知机制。
  • 主线程中开启两个线程,线程1先开始执行,但是线程1必须等待线程2执行完成后才能继续执行,那么线程1在需要等待的地方调用wait方法,线程2执行完成后执行notify方法通知线程1可以继续执行了,需要注意的是,调用wait和notify的对象需要是同一个对象的同一个实例。
  1. Thread.join():
  • Thread.join()是Thread类的方法,用于等待调用join()方法的线程执行完毕。
  • 当一个线程调用另一个线程的join()方法时,它会被阻塞,直到被调用的线程执行完毕才会继续执行。
  • Thread.join()通常用于主线程等待其他线程执行完毕后再继续执行的场景。
  • 在主线程中创建一个子线程,并开始执行子线程的任务,此时可以在主线程中调用子线程的join方法,效果就是主线程等待子线程执行完成,然后才会继续执行主线程的任务。

总的来说,Object.wait()用于线程间的等待和通知,而Thread.join()用于线程间的协作,让一个线程等待另一个线程执行完毕。

三. 什么是CAS

CAS(Compare and Swap,比较并交换)是一种并发编程中常用的原子操作,用于实现多线程环境下的同步和互斥。本质上来讲CAS是一种无锁的解决方案,也是一种基于乐观锁的操作,可以保证在多线程并发中保障共享资源的原子性操作,相对于synchronized或Lock来说,是一种轻量级的实现方案。CAS操作包括三个操作数:内存位置(V)、期望的值(A)和新值(B)。如果内存位置的当前值等于期望的值,则用新值来更新内存位置的值,否则不做任何操作。CAS操作具有原子性,不会被其他线程中断,因此可以避免了传统锁机制中的死锁和竞争等问题,提高了并发性能。

四. ABA问题

虽然使用CAS可以实现非阻塞式的原子性操作,但是会产生ABA问题,ABA问题出现的基本流程:

  • 进程P1在共享变量中读到值为A;
  • P1被抢占了,进程P2执行;
  • P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占;
  • P1回来看到共享变量里的值没有被改变,于是继续执行;

虽然P1以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA问题最容易发生在lock free的算法中的,CAS首当其冲,因为CAS判断的是指针的地址。如果这个地址被重用了呢,问题就很大了(地址被重用是很经常发生的,一个内存分配后释放了,再分配,很有可能还是原来的地址)。
维基百科上给了一个形象的例子:你拿着一个装满钱的手提箱在飞机场,此时过来了一个火辣性感的美女,然后她很暖昧地挑逗着你,并趁你不注意,把用一个一模一样的手提箱和你那装满钱的箱子调了个包,然后就离开了,你看到你的手提箱还在那,于是就提着手提箱去赶飞机去了。
ABA问题的解决思路就是使用版本号:在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。
另外,从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值,就是添加了一个类似于版本号的属性,如果版本号不一致就不能操作了。

五. synchronized和ReentrantLock的区别

  1. 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用于代码块。
  2. 获取锁和释放锁的机制不同:synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要手动加锁和释放锁。
  3. 锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁。
  4. 响应中断不同:ReentrantLock 可以使用 lockInterruptibly 获取锁并响应中断指令,而 synchronized 不能响应中断,也就是如果发生了死锁,使用 synchronized 会一直等待下去,而使用 ReentrantLock 可以响应中断并释放锁,从而解决死锁的问题
  5. 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。

六. synchronized原理

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充,Synchronized用的锁就是存在Java对象头里的,对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针),如果对象是数组对象,那么这个对象的对象头还会存储数组长度。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,如对象的HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。而且Mark Word中的LockWord存储了指向monitor的起始地址,它是实现轻量级锁和偏向锁的关键。

在HotSpot虚拟机中,Synchronized 的原理就是基于对象监视器(Object Monitor)实现的,每个 Java 对象都可以用作一个监视器,它与对象实例绑定。当一个线程进入一个 synchronized 方法或代码块时,它首先尝试获取对象的监视器。如果监视器没有被其他线程占用,那么当前线程就会获取到监视器并继续执行;如果监视器已经被其他线程占用,那么当前线程就会被阻塞,直到监视器被释放。当线程执行完 synchronized 方法或代码块时,它会释放对象的监视器,这样其他线程就可以获取监视器并继续执行。
synchronized对代码块加锁时,需要依靠两个指令 monitorenter 和 monitorexit,在进入代码块前执行 monitorenter 指令,在离开代码快前执行 monitorexit 指令,具体流程是:

  1. 执行 monitorenter 指令后,当前线程试图获取对象所对应的 monitor 的持有权,当monitor的进入计数器为0,则该线程可以成功获取 monitor,并以CAS的方式将计数器值设置为1,此时取锁成功。
  2. 如果当前线程已经拥有该对象 monitor 的所有权,那它可以进入这个 monitor ,重入计数器的的值加1。
  3. 如果其他线程已经拥有该对象 monitor 的所有权,那么当前线程将会被阻塞,直到正在执行的线程执行完毕,即 monitorexit 指令被执行,执行线程将释放 monitor锁并将计数器值设为0。

synchronized对方法的加锁时,不依靠 monitorenter 和 monitorexit指令,synchronized修饰方法时,会在访问标识符(flags)中加入ACC_SYNCHRONIZED标识,当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态,在HotSpot虚拟机中,Monitor的主要数据结构是:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; // 标识拥有该monitor的线程
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列,_WaitSet_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:

  1. 如果当前对象是无锁状态,那么会设置当前对象的锁的持有者为当前线程,并且成为偏向锁
  2. 当一个线程尝试获取对象的锁时,如果对象的锁已经被其他线程持有,那么该线程会被放入对象的 _EntryList 队列中,等待锁的释放。当持有锁的线程释放锁时,会从 _EntryList 中选择一个或多个线程唤醒,让它们尝试重新竞争锁。
  3. 当线程获取到对象的monitor后,进入 _owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
  4. 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 _WaitSet集合中等待被唤醒;
  5. 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);

七. 偏向锁、轻量级锁、重量级锁是什么

偏向锁、轻量级锁和重量级锁是Java中用于实现线程同步的三种锁状态,它们的特点和适用场景有所不同。

  1. 偏向锁(Biased Locking):偏向锁是为了在无竞争的情况下提高性能而引入的概念。当一个线程访问一个同步块并获取锁时,会在对象头上的标记位中记录该线程的ID。接下来,当这个线程再次进入同步块时,不需要再次竞争锁,而是直接获取。偏向锁的撤销会在其他线程尝试获取锁时触发。当 线程a 访问代码块并获取锁对象时,会通过 CAS 在 Mark Word 中记录偏向的锁的 threadID,因为偏向锁不会主动释放锁,因此以后再次获取锁的时候,需要比较当前线程的 threadID 和 Mark Word 中的threadID是否一致,如果一致,则无需使用CAS来加锁、解锁;如果不一致,则是因为有其他线程如 线程b 来竞争该锁,而偏向锁时不会主动释放锁,因此 Mark Word 存储的还是 线程a 的threadID,那么需要查看 Mark Word 中记录的 线程a 是否存活,如果没有存活,那么锁对象被重置为无锁状态,线程b 可以竞争将其设置为偏向锁;如果存活,那么立刻查找 线程a 的栈帧信息,如果还是需要继续持有这个锁,那么暂停当前 线程a,撤销偏向锁,升级为轻量级锁,如果 线程a 不再使用该锁,那么将锁状态设为无锁状态,重新偏向新的线程。
  2. 轻量级锁(Lightweight Locking):如果偏向锁失败,表示有其他线程竞争锁,锁会升级为轻量级锁。轻量级锁使用CAS(Compare and Swap)操作来避免多个线程同时竞争锁。如果某个线程尝试获取轻量级锁失败,那么它会膨胀为重量级锁。 轻量级锁是由偏向锁升级而来,它考虑的情况是竞争锁的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,性能的浪费就太大了,因此这个时候就干脆不阻塞这个线程,让它CAS自旋等待锁释放。 轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,轻量级锁在加锁过程中,用到了自旋锁来避免因为多线程的竞争而把线程马上在操作系统层面挂起的情况。例如:线程a 获取轻量级锁时会先把锁对象的 Mark Word 复制一份到 线程a 的栈帧中存储锁记录的 LockRecord 中,然后使用cas操作把对象头的 Mark Word 的内容替换为 线程a 的 LockRecord 地址,并将Lock record里的owner指针指向对象的 Mark Word,如果在 线程a 复制对象头的同时(在 线程a cas之前),线程b 也准备获取锁,复制了对象头到 线程b 的锁记录空间中,但是在 线程b cas 的时候,发现 线程a 已经把对象头替换了,则 线程b 获取锁失败,那么 线程b 就尝试使用自旋锁来等待 线程a 释放锁。
    当一个线程在自旋等待锁释放时,也不能一直不停的自旋,自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。DK 1.6引入了更加聪明的自旋锁,即自适应自旋锁,线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
  3. 重量级锁(Heavyweight Locking):当多个线程竞争同一个锁时,锁会膨胀为重量级锁。重量级锁使用操作系统的同步机制来实现,比如互斥量。在重量级锁下,除了持有锁的线程可以进入临界区外,其他线程都会被阻塞。

八. 锁升级的过程

Java中的锁升级过程涉及从无锁状态到重量级锁状态的转变,包括偏向锁、轻量级锁和重量级锁三种状态。过程大致如下:

  1. 无锁状态:
  • 初始状态下,对象处于无锁状态。
  • 当第一个线程尝试获取对象的锁时,对象会被升级为偏向锁状态。
  1. 偏向锁状态:
  • 如果持有该偏向锁的线程再次尝试获取这个锁,那么会直接进入锁,不再需要竞争
  • 如果其他线程尝试获取对象的锁,会检查对象的偏向锁状态,并且持有偏向锁的线程不是当前线程,则偏向锁会被撤销并升级为轻量级锁状态。
  1. 轻量级锁状态:
  • 如果偏向锁升级为轻量级锁,其他线程尝试获取锁时,会尝试使用自旋CAS的方式来竞争锁。
  • 如果自旋CAS操作成功,则当前线程获得了轻量级锁,继续执行同步代码块;如果CAS操作失败,则表示有竞争,当前线程会进入对象monitor的 _EntryList 中等待。
  • 如果自旋CAS尝试获取锁失败,那么轻量级锁就会膨胀为重量级锁。
  1. 重量级锁状态:
  • 如果轻量级锁竞争失败,锁就会膨胀为重量级锁。
  • 当一个线程尝试获取对象的锁时,如果对象已经是重量级锁,则当前线程会进入对象的 _EntryList 中等待。
  • 持有重量级锁的线程释放锁时,会通知 _EntryList 中的一个或多个线程来竞争锁。
  1. 释放锁:
  • 当持有锁的线程执行完同步代码块或者发生异常等会释放锁。
  • 如果有线程在对象的 _WaitSet 中等待,会选择一个线程唤醒并移动到对象的 _EntryList 中,等待获取锁。
  • 如果没有线程在 _WaitSet 中等待,那么 _EntryList 中的线程会继续竞争锁。
  • 当同时存在等待线程在 _WaitSet 和 _EntryList 中时,会优先从 _WaitSet 中选择一个线程唤醒,并将其移动到 _EntryList 中,以便参与锁的竞争,在从_WaitSet进入_EntryList时,虚拟机会尽量遵从公平性、先进先出,确保每个线程都有概率参与锁竞争,线程优先级可以在一定程度上提升参与竞争的概率,但并不能完全保证。
  • 在锁被释放的时候,_EntryList中的线程会被唤醒并且以CAS的方式尝试获取锁,最终只会有一个线程成功获取到锁

九. volatile关键字实现原理

  1. 计算机多级缓存 计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。 也就是说,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。 随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L3),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。那么,在有了多级缓存之后,程序的执行就变成了:当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。 随着计算机能力不断提升,开始支持多线程。那么问题就来了,多核CPU,多线程。每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。所以就有了缓存一致性协议
  2. 缓存一致性协议 当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
  3. 什么是java的内存模型:Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。而JMM就作用于工作内存和主存之间数据同步过程,他规定了如何做数据同步以及什么时候做数据同步。
  4. Java 内存模型中的可见性、原子性和有序性
  • 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
  • 有序性:即程序执行的顺序按照代码的先后顺序执行
  1. volatile的作用 volatile保证不同线程对同一个变量进行操作时的可见性;禁止进行指令重排序
  2. volatile的原理 volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能: a. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成; b. 它会强制将对缓存的修改操作立即写入主存; c. 如果是写操作,它会导致其他CPU中对应的缓存行无效。 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,会从内存中重新读取新值。例如当线程1修改volatile修饰的变量时,会立即写入主存,并把主存当前块在其它cache中缓存都置为无效。其他线程所在cache探听总线,当发现自己块对应的主存已修改时,就把本cache中对应块置为无效状态。当处理器要对这个数据读写时,发现块已失效,会重新从主存中调入块到cache后,再读取,那么读取到的数据就是最新的。 所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
  3. volatile在Double Check中的作用
public class TestInstance{
  private volatile static TestInstance instance;
  public static TestInstance getInstance(){   //1
    if(instance == null){   //2
      synchronized(TestInstance.class){   //3
        if(instance == null){   //4
          instance = new TestInstance(); //5
        }
      }
    }
    return instance;   //6
  }
}

在这个双重锁实现单例的例子中,明明已经加锁了,但是为什么还需要给instance添加volatile呢?原因就是在instance = new TestInstance(); 这一行代码中,因为创建对象并赋值给属性并不是一个原子操作,这个操作可以分为三步:

1. memory = allocate() 		//分配内存
2. ctorInstanc(memory)		//初始化对象
3. instance = memory   		//设置instance指向刚分配的地址

上面的代码在编译运行时,可能会出现重排序从1->2->3排序为1->3->2。在多线程的情况下会出现以下问题。当线程A在执行第5行代码时,B线程进来执行到第2行代码。假设此时A执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。那么由于A线程执行了c导致instance指向了一段地址,所以B线程判断instance不为null,会直接跳到第6行并返回一个未初始化的对象。
所以使用了volatile的禁止指令重排序功能,Double Check才能安全的实现单例。
8. 指令重排序 代码在实际执行过程中,并不全是按照编写的顺序进行执行的,在保证单线程执行结果不变的情况下,编译器或者CPU可能会对指令进行重排序,以提高程序的执行效率。就例如上面的Double Check例子。 volatile防止指令重排序是通过内存屏障来实现的。编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

十. 什么是AQS,什么是CLH

AQS:
AQS是Java中的一个抽象队列同步器(AbstractQueuedSynchronizer)类,它提供了一种实现同步器的框架和实现方式。它是Java并发编程中的一个重要组成部分,广泛用于实现ReentrantLock、Semaphore、CountDownLatch等同步工具类。
AQS的核心思想是利用一个先进先出(FIFO)的双向队列来管理线程的竞争和等待。AQS提供了两种模式:独占模式和共享模式。独占模式是指只有一个线程可以持有同步状态,如ReentrantLock;共享模式是指多个线程可以同时持有同步状态,如Semaphore。
AQS的具体实现方式是通过维护一个volatile变量state表示同步状态,当state为0时表示没有线程占用同步状态,当state为1时表示有一个线程占用同步状态。当多个线程竞争同步状态时,只有一个线程可以成功占用同步状态,其余线程将加入到AQS的同步队列中等待。当占用同步状态的线程释放同步状态时,AQS会从同步队列中选择一个线程唤醒,使其重新尝试获取同步状态。
总之,AQS提供了一种高效且灵活的实现同步器的方式,可以满足不同的并发编程需求。
CLH:
CLH锁其实就是一种是基于逻辑队列非线程饥饿的一种自旋公平锁,由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁。
自旋锁有两个问题:第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况;第二是性能问题。在实际的多处理上运行的自旋锁在锁竞争激烈时性能较差。
CLH 锁是对自旋锁的一种改进,有效的解决了以上的两个缺点。首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。其次锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。
CLH 锁数据结构很简单,类似一个链表队列,所有请求获取锁的线程会排列在链表队列中,自旋访问队列中前一个节点的状态。当一个节点释放锁时,只有它的后一个节点才可以得到锁。CLH 锁本身有一个队尾指针 Tail,它是一个原子变量,指向队列最末端的 CLH 节点。
每一个 CLH 节点有两个属性:所代表的线程和标识是否持有锁的状态变量。当一个线程要获取锁时,它会对 Tail 进行一个 getAndSet 的原子操作。该操作会返回 Tail 当前指向的节点,也就是当前队尾节点,然后使 Tail 指向这个线程对应的 CLH 节点,成为新的队尾节点。入队成功后,该线程会轮询上一个队尾节点的状态变量,当上一个节点释放锁后,它将得到这个锁。
CLH锁原理如下:

  1. 首先有一个尾节点指针,通过这个尾结点指针来构建等待线程的逻辑队列,因此能确保线程先到先服务的公平性,因此尾指针可以说是构建逻辑队列的桥梁;此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题;
  2. 通过等待锁的每个线程在自己的某个变量上自旋等待,这个变量将由前一个线程写入。由于某个线程获取锁操作时总是通过尾节点指针获取到前一线程写入的变量,而尾节点指针又是原子引用类型,因此确保了这个变量获取出来总是线程安全的。

AQS如何使用的CLH:
CLH 锁作为自旋锁的改进,有以下几个优点:

  1. 性能优异,获取和释放锁开销小:CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。在释放锁的开销也因为不需要使用 CAS 指令而降低了。
  2. 公平锁:先入队的线程会先得到锁。
  3. 实现简单,易于理解。
  4. 扩展性强。

当然,它也有两个缺点:第一是因为有自旋操作,当锁持有时间长时会带来较大的 CPU 开销。第二是基本的 CLH 锁功能单一,不改造不能支持复杂的功能。
针对 CLH 的缺点,AQS 对 CLH 队列锁进行了一定的改造。针对第一个缺点,AQS 将自旋操作改为阻塞线程操作。针对第二个缺点,AQS 对 CLH 锁进行改造和扩展,原作者 Doug Lea 称之为“CLH 锁的变体”。AQS 中的对 CLH 锁数据结构的改进主要包括三方面:扩展每个节点的状态、显式的维护前驱节点和后继节点以及诸如出队节点显式设为 null 等辅助 GC 的优化。正是这些改进使 AQS 可以支撑 j.u.c 丰富多彩的同步器实现。

  • 12
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值