JAVA并发编程总结

一、基础知识

1.1 线程安全

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

CAP理论

  • 原子性

我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性.

  • 可见性

当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。

  • 顺序性

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。如果在被线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。

BASE理论

  • Basically Available(基本可用)
    响应时间上的损失:正常情况下,处理用户请求需要0.5s返回结果,但是由于系统出现故障,处理用户请求的时间变成3s。
    系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的非核心功能无法使用。
  • Soft state(软状态)
    数据同步允许一定的延迟。
  • Eventually consistent(最终一致性)
    系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态,不要求实时。

volatile
关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。

  • 保证此变量对所有线程的可见性。但是操作并非原子操作,并发情况下不安全。(每次更新的值都会同步到主内存)
  • 禁止指令重排序优化。(通过插入内存屏障保证一致性。)

因为volatile不确保原子性,所以不能完全保证线程安全,仅在以下条件才应该使用:

  • 对变量的写入操作不依赖变量的当前值,或者确保只有单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中
  • 在访问变量时不需要加锁

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

ThreadLocal
ThreadLocal,很多地方叫做线程本地变量,也有些地方叫做线程本地存储,ThreadLocal 的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。

  1. 每个线程中都有一个自己的 ThreadLocalMap 类对象,可以将线程自己的对象保持到其中,各管各的,线程可以正确的访问到自己的对象。
  2. 将一个共用的 ThreadLocal 静态实例作为 key,将不同对象的引用保存到不同线程的ThreadLocalMap 中,然后在线程执行的各处通过这个静态 ThreadLocal 实例的 get()方法取得自己线程保存的那个对象,避免了将这个对象作为参数传递的麻烦。

ThreadLocal内存泄露问题
线程池使用完及时释放value

1.2 线程的使用

线程的创建

  1. 继承Thread类
  2. 实现Runable接口
  3. 实现Callable接口(有返回值Future的线程)
  4. 基于线程池的方式

终止线程的4种方式

  1. 正常运行结束
  2. 使用退出标志
  3. 使用interrupt()方法
  4. 使用stop()方法

线程相关的基本方法
wait() :调用该方法的线程进入 WAITING 状态,只有等待另外线程的通知或被中断才会返回,需要注意的是调用 wait()方法后,会释放对象的锁。因此,wait 方法一般用在同步方法或同步代码块中。
sleep() :sleep 导致当前线程休眠,与 wait 方法不同的是 sleep 不会释放当前占有的锁,sleep(long)会导致线程进入 TIMED-WATING 状态,而 wait()方法会导致当前线程进入 WATING 状态
yield () :yield 会使当前线程让出 CPU 执行时间片,与其他线程一起重新竞争 CPU 时间片。一般情况下,优先级高的线程有更大的可能性成功竞争得到 CPU 时间片,但这又不是绝对的,有的操作系统对线程优先级并不敏感。
interrupt():中断一个线程,其本意是给这个线程一个通知信号,会影响这个线程内部的一个中断标识位。这个线程本身并不会因此而改变状态(如阻塞,终止等)。
join() :等待其他线程终止,在当前线程中调用一个线程的 join() 方法,则当前线程转为阻塞状态,回到另一个线程结束,当前线程再由阻塞状态变为就绪状态,等待 cpu 的宠幸。
notify():随机唤醒此对象上等待的单个线程。
notifyAll():唤醒此对象上等待的所有线程。

如何在两个线程之间共享数据
Java 里面进行多线程通信的主要方式就是共享内存的方式

  1. 将数据抽象成一个类,并将数据的操作作为这个类的方法
  2. Runnable 对象作为一个类的内部类

1.3 同步容器类

Vector、HashTable
ConcurrentHashMap、CopyOnWriteArrayList
ConcurrentQueue、ConcurrentSkipListMap、ConcurrentSkipListSet

JAVA集合

1.4 同步工具类

闭锁

闭锁可以用来确保某些活动指导其他活动都完成都才继续执行。
闭锁包括一个计数器,该计数器被初始化为一个整数,表示需要等待的时间数量。countDown方法递减计数器,表示有一个时间已经发生了,而await方法等待计数器达到零,这表示所有需要等待的时间都已经发生。吐过计数器的值非零,那么await会一直阻塞知道计数器为零,或者等待的线程中断或超时。

CountDownLatch和FutureTask都是闭锁的实现。

信号量

计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的输了。还可以用来实现某种资源池,或者对容器施加边界。
Semaphore中管理这一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可,并在使用以后释放许可。如果没有许可,那么acquire将阻塞知道有许可(或者中断或超时)。release方法将返回一个许可给信号量。

栅栏

栅栏(Barrier)类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时达到栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用户等待其他线程。
当线程到达栅栏门位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。

CountDownLatch、CyclicBarrier和Semaphore 使用示例及原理

二、线程池

2.1 线程池参数

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) { ... }
  1. corePoolSize(核心线程数:线程池的初始大小,没有任务执行时的线程池大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
  2. maximumPoolSize(最大线程数):表示可同时活动的线程数量上限。
  3. keepAliveTime(存活时间 :如果某个线程的空闲时间超过了存活时间,将被标记为可回收,当线程数量超过核心线程数大小时,这个线程将被终止。
  4. unit(时间单位)
  5. workQueue(任务队列):任务提交速度大于线程池处理速度时,会被加入到BlockingQueue队列中等待。任务队列分为3种:
    1. ArrayBlockingQueue(有界队列):一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。
    2. LinkedBlockingQueue(无界队列):一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue,静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
    3. SynchronousQueue(同步移交):同步移交不放入队列,直接移交给线程,如果没有空闲线程则新建线程,如线程数达到最大,则执行拒绝策略。
    4. PriorityBlockingQueue(优先队列):一个具有优先级的无限阻塞队列。
  6. threadFactory(线程工厂):线程池的创建都是通过线程工厂的newThread方法调用来创建,可以通过定制线程工厂方法来实现个性化的需求(如指定一些异常捕获或者信息记录等)。
  7. handler(拒绝策略):当达到最大线程数,且队列是有界时,会执行拒绝策略。有4种拒绝策略:
    1. Abort(中止):抛出异常RejectExecutionException
    2. Discard(抛弃):新任务被提交后直接被丢弃掉。
    3. Discard-Oldest(抛弃最旧的) :策略会抛弃下一个将被执行的任务,也就是队列最前面的,然后提交新的任务。
    4. Caller-Runs(调用者运行) :把这个任务交于提交任务的线程执行。由于主线程执行任务所以有一段时间不能提交任务,也让线程池的任务有一定时间处理。

2.2 工作流程

当向线程池提交一个新的任务时

  1. 当工作线程数小于核心线程数时,直接创建新的核心工作线程。
  2. 当工作线程数大于核心线程数时,就需要尝试将任务添加到阻塞队列中去。
  3. 如果队列已满,则尝试创建非核心线程。
  4. 若队列已满且线程达到最大线程数,则执行拒绝策略。
  5. 在线程处于空闲状态的时间超过keepAliveTime时间时,正在运行的线程数量超过corePoolSize,该线程将会被认定为空闲线程并停止。因此在线程池中所有线程任务都执行完毕后,线程池会收缩到corePoolSize大小。

2.3 Executor框架

newFixedThreadPool:固定长度线程池,线程池的核心线程数和最大线程数设置为指定的值,使用无界队列。
newCachedThreadPool:可缓存的线程池,线程池的最大线程数为Integer.MAX_VALUE,核心线程数为0,超时时间设置为1分钟,使用无界队列。
newSingleThreadExecutor:单线程的线程池
newScheduledThreqadPool:定时执行的线程池

Q: new Thread()和newSingleThreadExecutor()都是创建一个线程处理,为什么还需要存在单个线程的线程池呢?
A: new Thread()每次创建新的线程,newSingleThreadExecutor()使用同一个线程,减少线程创建和销毁的消耗。

三、活跃性与性能

3.1 死锁

3.1.1 死锁的形成条件
  1. 互斥条件: 进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  2. 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  3. 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
3.1.2 死锁的处理方法
  • 预防死锁:通过设置某些限制条件,去破坏产生死锁的四个必要条件中的一个或几个条件,来防止死锁的发生。
  • 避免死锁:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
  • 检测死锁:允许系统在运行过程中发生死锁,但可设置检测机构及时检测死锁的发生,并采取适当措施加以清除。
  • 解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来。

(1)预防死锁
破坏死锁形成的四个条件

  1. 破坏“互斥”条件
      就是在系统里取消互斥。若资源不被一个进程独占使用,那么死锁是肯定不会发生的。但一般来说在所列的四个条件中,“互斥”条件是无法破坏的。因此,在死锁预防里主要是破坏其他几个必要条件,而不去涉及破坏“互斥”条件。
      注意:互斥条件不能被破坏,否则会造成结果的不可再现性。
  2. 破坏“请求与保持”条件
      破坏“占有并等待”条件,就是在系统中不允许进程在已获得某种资源的情况下,申请其他资源。即要想出一个办法,阻止进程在持有资源的同时申请其他资源。
      方法一:创建进程时,要求它申请所需的全部资源,系统或满足其所有要求,或什么也不给它。这是所谓的 “ 一次性分配”方案。
      方法二:要求每个进程提出新的资源申请前,释放它所占有的资源。这样,一个进程在需要资源S时,须先把它先前占有的资源R释放掉,然后才能提出对S的申请,即使它可能很快又要用到资源R。
  3. 破坏“不可抢占”条件
      破坏“不可抢占”条件就是允许对资源实行抢夺。
      方法一:如果占有某些资源的一个进程进行进一步资源请求被拒绝,则该进程必须释放它最初占有的资源,如果有必要,可再次请求这些资源和另外的资源。
      方法二:如果一个进程请求当前被另一个进程占有的一个资源,则操作系统可以抢占另一个进程,要求它释放资源。只有在任意两个进程的优先级都不相同的条件下,方法二才能预防死锁。
  4. 破坏“循环等待”条件
      破坏“循环等待”条件的一种方法,是将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序(升序)提出。这样做就能保证系统不出现死锁。

(2)避免死锁
预防死锁和避免死锁的区别:
预防死锁是设法至少破坏产生死锁的四个必要条件之一,严格的防止死锁的出现,而避免死锁则不那么严格的限制产生死锁的必要条件的存在,因为即使死锁的必要条件存在,也不一定发生死锁。避免死锁是在系统运行过程中注意避免死锁的最终发生。

常用避免死锁的方法:

  1. 加锁顺序(线程按照一定的顺序加锁)
  2. 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
  3. 死锁检测

常用算法:
资源分配图算法银行家算法

资源分配图算法与银行家算法

(3)检测死锁
死锁检测与恢复是指系统设有专门的机构,当死锁发生时,该机构能够检测到死锁发生的位置和原因,并能通过外力破坏死锁发生的必要条件,从而使得并发进程从死锁状态中恢复出来。

(4)解除死锁
死锁解除的主要方法有:

  1. 资源剥夺法。挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但应防止被挂起的进程长时间得不到资源,而处于资源匮乏的状态。
  2. 撤销进程法。强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级和撤销进程代价的高低进行。
  3. 进程回退法。让一(多)个进程回退到足以回避死锁的地步,进程回退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。

死锁,死锁的四个必要条件以及处理策略

四.高级主题

4.1 显式锁

4.1.1 synchronized

(1)Monitor
Monitor 被翻译为监视器或管程

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

在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }
  • synchronized用在方法上:标志ACC_SYNCHRONIZED。代表的是当线程执行到方法后会检查是否有这个标志,如果有的话就会隐式的去调用monitorenter和monitorexit两个命令来将方法锁住。
    JAVA并发编程总结_第1张图片

  • synchronized用在代码块上:在同步块的前后形成monitorenter和monitorexit两个字节码指令。
    JAVA并发编程总结_第2张图片

  1. 在执行monitorenter指令的时候,首先要去尝试获取对象的锁(获取对象锁的过程,其实是获取 monitor对象的所有权的过程)。
  2. 如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一。
  3. 而在执行monitorexit指令时会将锁计数器减一。一旦计数器的值为零,锁随即就被释放了。
  4. 如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

(2)JAVA对象
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

  • 对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
    JAVA并发编程总结_第3张图片

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

Mark Word部分的存储结构(32位虚拟机):
JAVA并发编程总结_第4张图片
(3)对象头中Mark Word与线程中Lock Record
在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝至关重要。

Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。如下图所示为Lock Record的内部结构:
JAVA并发编程总结_第5张图片
synchronized详解

(4)synchronized的优化
JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
何谓自旋锁?
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。

适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

锁粗化

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

synchronized 锁的升级过程
synchronized 锁的四种状态:无锁状态偏向锁状态轻量级锁状态重量级锁,状态锁的状态根据竞争激烈的程度从低到高不断升级。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。其中识别是不是同一个线程一只获取锁的标志是在上面提到的对象头Mark Word(标记字段)中存储的。(花销除了第一次CAS,后续只需要判断Mark Word中的线程id是否为访问的线程)

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。(相比偏向锁需要自旋以及CAS操作替换线程id,但不会阻塞线程)

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候(默认10次),还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。这时候也就成为了原始的Synchronized的实现。

JAVA并发编程总结_第6张图片

synchronized锁升级详细过程

4.1.2 Lock

(1)ReentrantLock
ReentrantLock实现Lock接口,Lock接口中定义了lockunlock相关操作,并且还存在newCondition方法,表示生成一个条件。
ReentrantLock 类内部总共存在SyncNonfairSyncFairSync三个类,NonfairSync与 FairSync类继承自 Sync类,Sync类继承自 AbstractQueuedSynchronizer抽象类。

ReentrantLock结构:
JAVA并发编程总结_第7张图片
通过分析 ReentrantLock的源码,可知对其操作都转化为对 Sync对象的操作,由于 Sync继承了 AQS,所以基本上都可以转化为对 AQS的操作。如将 ReentrantLock的 lock函数转化为对 Sync的 lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到 Sync的不同子类。所以可知,在 ReentrantLock的背后,是 AQS对其服务提供了支持。

synchronized和ReentrantLock的区别:

区别 ReentrantLock synchronized                       
底层实现 API层面 JAVA关键字
锁机制 基于AQS 基于Monitor
释放形式 手动释放 自动释放
灵活性 支持响应中断(lockInterruptibly)、尝试获取锁(trylock)、超时(timeout) 不灵活
锁类型 非公平锁&公平锁 非公平锁
条件绑定 通过Condition绑定多个条件 不支持

在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一中高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized。

ReentrantLock 锁详解

Condition 类和 Object 类锁方法区别区别

  1. Condition 类的 awiat 方法和 Object 类的 wait 方法等效
  2. Condition 类的 signal 方法和 Object 类的 notify 方法等效
  3. Condition 类的 signalAll 方法和 Object 类的 notifyAll 方法等效
  4. ReentrantLock 类可以唤醒指定条件的线程,而 object 的唤醒是随机的

可重入锁
可重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。

(2)AbstractQueueSynchronizer(AQS)

AQS 定义了一套多线程访问共享资源的同步器框架,AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器, 比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask(jdk1.7) 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。

它维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列)。

AQS 定义两种资源共享方式:Exclusive 独占资源-ReentrantLock,Share 共享资源-Semaphore/CountDownLatch

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒 时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

同步器的实现是 ABS 核心(state 资源状态计数)
同步器的实现是 ABS 核心,以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。

AQS 详细介绍

AQS为什么使用双向链表
双向链表可以支持 常量O(1) 时间复杂度的情况下找到前驱结点,基于这样的特点。
双向链表在插入和删除操作的时候,要比单向链表简单、高效。

  1. 第一个方面,没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。所以线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从head节点开始遍历,性能非常低。
    JAVA并发编程总结_第8张图片

  2. 第二个方面,在Lock接口里面有一个,lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。这个时候,被中断的线程的状态会修改成CANCELLED。被标记为CANCELLED状态的线程,是不需要去竞争锁的,但是它仍然存在于双向链表里面。意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。在这种情况下,如果是单向链表,就需要从Head节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。
    JAVA并发编程总结_第9张图片

  3. 第三个方面,为了避免线程阻塞和唤醒的开销,所以刚加入到链表的线程,首先会通过自旋的方式尝试去竞争锁。但是实际上按照公平锁的设计,只有头节点的下一个节点才有必要去竞争锁,后续的节点竞争锁的意义不大。否则,就会造成羊群效应,也就是大量的线程在阻塞之前尝试去竞争锁带来比较大的性能开销。所以为了避免这个问题,加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是不是头节点,如果不是头节点,就没必要再去触发锁竞争的动作。所以这里会涉及到前置节点的查找,如果是单向链表,那么这个功能的实现会非常复杂。
    JAVA并发编程总结_第10张图片

(3)CompareAndSwap(CAS)
CAS是乐观锁的一种实现,CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。

CPU对CAS的支持:
CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。所以利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。

A: ABA问题:假设有一个变量 A ,修改为B,然后又修改为了 A,实际已经修改过了,但 CAS 可能无法感知,造成了不合理的值修改操作。
Q: 加上版本号,更新的时候检查版本号,并更新引用的值和版本号。

一文彻底搞懂CAS实现原理

(4)原子变量类
AcomicInteger、AtomicLong、AtomicBoolean等

  1. 维护一个volatile修饰的int型变量value
  2. 使用CAS保证原子性操作
//维护一个volatile修饰的int型变量value
private volatile int value;
 
public AtomicInteger(int initialValue) {
    value = initialValue;
}
//基于CAS的原子性操作
public final int getAndSet(int newValue) {
        return unsafe.getAndSetInt(this, valueOffset, newValue);
}

public final boolean compareAndSet(int expect, int update) {
	   return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

4.2 JAVA内存模型(Java Memory Model)

内存模型是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式。
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。

Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间无法直接访问对方本地内存中的变量。

JAVA并发编程总结_第11张图片

全面学习掌握Java内存模型

本文主要用作知识点梳理,以上很多内容都为概述,详细内容附有写的不错的博客地址。日后有时间和需求再做详细解析和补充。

参考书籍:《JAVA并发编程实战》

你可能感兴趣的:(java,面试)