Skip to content

Latest commit

 

History

History
executable file
·
162 lines (115 loc) · 14.2 KB

并发编程.md

File metadata and controls

executable file
·
162 lines (115 loc) · 14.2 KB

0、并发编程基础

0.1 线程状态

Java中线程状态分为NEW,RUNABLE, WAITING, TIME_WAITING, BLOCKED, TREMINATED 其中RUNABLE由操作系统中的RNNING,READY两个状态组成。

由图中可以发现,执行一个线程的start方法后便进入了RUNABLE状态,在RUNABLE状态中,线程可以通过yield()方法暂时让出CPU资源,但其状态仍保持不变。此外,只有等待进入synchronized关键字修饰的方法或者代码块时才会导致一个线程进入BLOCKED状态,而其他方法都只是使得线程进入WAITING或TIME_WAITING状态。我们后面会讲到Java中的RenetrandLock类中使用的是LockSupport类执行锁的相关操作,所以ReentrandLock并不会使得线程进入BLOCKED状态

0.2 concurrent包的实现

下面从底向上介绍每一部分

0.2.1 volatile变量

volatile变量与Java的内存模型有关,在Java中,所有的实例域,静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理器参数不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是一个抽象概念,他涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

言归正传,volatile变量自身具有以下特性

  • 可见性 : 对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写。
  • 原子性 : 对任意单个volatile变量的读/写具有原子性。

JMM使用了内存屏障保证其性质,内存屏障是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,语义上,内存屏障之前的所有写操作都要写入主内存,内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。JMM会在volatile写的前后和读的后面插入内存屏障以保证其性质。

0.2.2 CAS

CAS的全称是 Compare and Swap,Java中的compareAndSet操作调用CPU的CAS指令以帮助concurrent包实现区别于synchronized同步锁的一种乐观锁。 CAS有三个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,讲内存值V修改为B,否则什么都不做;稍后我们会讲到原子变量类和AQS等都使用到了CAS。

0.2.3 AQS

AQS全称是AbstractQueuedSynchronizer 同步器的主要使用方式是继承,子类通过继承同步器并实现他的抽象方法来管理同步状态。同步器的实现基于内部的一个双向队列,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成一个节点并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒。(注意这里说的阻塞并不是将线程设置为阻塞态,而是通过Looksupport将其设置为WAITING状态)

独占式同步状态获取与释放

在获取同步状态时,同步器维护一个同步队列,对于获取状态失败的线程,同步器使用CAS将其加入到队列队尾。节点在队列中自旋,但是只有头结点可以尝试获取同步状态,如果获取失败(非头结点一定获取失败)则将节点中的线程阻塞,节点仍处于自旋状态。已经获取同步状态的节点在执行完相应的逻辑后会调用release方法释放同步状态,此方法会唤醒头结点中的线程,这时由于头结点仍然处于获取状态的自旋过程,由于同步状态已经被释放,在不考虑有其他竞争的情况下,头结点可以获取同步状态成功并执行相应逻辑。这里提到不考虑其他竞争的问题,其实这个时间节点的行为决定了锁在实现的过程中公平与非公平的性质。即假设在队列头结点要获取同步状态时,如果有新的未入队的线程想要获取同步状态,如果我们允许其竞争同步状态,则实现的锁就是非公平的锁,因为锁的获取并不依赖与等待时间的长短。而如果我们在每一节点获取锁的时候,我们判断节点是否持有指向Head(队列的头部指针,不存储真实节点,前面提到的头部节点为Head->next)的指针,只允许持有指向Head指针的节点可以获取同步状态。这就保证了,只有在队列中的节点才可以获取同步状态,新加入的节点必须加入到队列的末尾。

共享式同步状态的获取与释放

共享式获取与独占式获取最主要的区别在于同一时刻是否有多个线程同时获取到同步状态。共享式获取的思想与信号量的思想一致,最初有一个人为设置的int类型的参数,表示最多可以允许几个线程同时获取同步状态。每个线程获取到同步状态后都会将参数值减一,而在释放的过程中则将其加一。参数的值的改变必须通过CAS操作保证同步。

独占式超时获取同步状态

背景 :

当一个线程被阻塞到synchronized之外时,对该线程执行中断操作,此时该线程的中断标志位会被改变,但是被阻塞的线程仍然处于阻塞状态不会返回,而在java5之后,同步器提供了acquireInterruptly方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出异常。

独占式超时获取同步状态可以被视作响应中断获取同步状态的增强版,在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要是计算出需要睡眠的时间间隔nonosTimeout,进入等待过程后状态获取线程会被LockSupport挂起相应的时间,为了防止过早的通知,nanosTimeout的计算公式为:nanosTimeout -= now - lastTime, 其中now为当前唤醒时间,lastime为上次唤醒时间。在制定的时间区间中获取不到同步状态函数会返回false,如果获取成功则返回true

以上细节的阐述基本就说明了各种上层锁的实现原理

  • ReentrantLock基于独占式同步状态获取,也即实现了公平与非公平锁
  • ReadWriteLock基于共享式同步状态获取

0.2.4 LockSupport工具

方法名称 描述
void park() 阻塞当前线程,如果调用unpark(Thread t)方法或者当前线程被中断,才能从park()方法返回
void parkNanos(long n) 在park()的基础上加上了超时返回,即阻塞当前线程最长不超过n纳秒
void parkUntil(long deadline) 阻塞当前线程,知道deadline时间
void unpark(Thread thread) 唤醒处于阻塞状态的线程thread

0.2.5 Condition

Condition的实现方式与Synchronized中的wait相似,为一个给定的Condition维护一个等待队列,在条件满足的情况下将等待队列中的一个或全部移动到同步队列。

0.3 synchronized

synchronized关键字可以修饰一个代码块或者一个函数,其指定的同步对象有如下情况:

  • 对于普通方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是Synchronized括号里面配置的对象

Java中每个对象都有一个与之关联的Monitor对象,JVM使用进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的实现细节不一样

  • 代码块同步使用的是 monitorentermonitorexit 指令实现的
  • 方法的同步稍微复杂一点,JVM为常量池中的该方法添加了ACC_SYNCHRONIZED标识符,当方法调用时,调用指令将会检查ACC_SYNCHRONIZED访问标识符是否被设置了,如果设置了,执行线程将先获取monitor,获取成功后才能执行方法体,方法执行完后再释放Monitor。在方法执行期间,其他任何线程都误发获得同一个Monitor对象。

锁的优化

偏向锁

JVM默认开启偏向锁,即对象的Mark word中的是否为偏向锁这一位的值为1。 假设一个新的未被加锁的对象A,当有一个线程T1想要获取A的锁的时候,他会判断偏向锁位是否为1,如果是,则通过CAS将MarkWord中的线程ID设置为自己线程的ID,然后执行同步体。

  • 如果没有其他线程尝试获取这个锁,则当前线程执行完成之后并不会将MarkWord中的ID置空。假设整个程序执行生命周期中,都没有其他的线程竞争锁,则每次线程T1获取A的锁时只需要简单的测试一下MarkWord中的ID是否为自身线程ID,而不是要进行monitorenter和monitorexit操作或者其他诸多CAS操作(如果是轻量级锁)。
  • 如果有线程T2竞争锁,T2首先检查MarkWord中的ID是否为自己的ID,当然这里检测发现不是,则T2通过CAS获取锁,如果这时T1已经执行完毕,则T2将会成功获得锁。这时JVM会将MarkWord中的ID设置为T2的ID。T2将继续以偏向锁的形式执行。而如果这时T1并没有执行完毕,T2获取锁失败。当到达全局安全点时(没有正在执行的字节码)如果T1仍然存活,则会将获得偏向锁的线程T1挂起,这时会将锁升级为轻量级锁,即将对象头中的锁标志位设置为00(轻量级锁),将对象MarkWord中的Lock record的指针指向持有锁的线程T1的栈中的Lock record(线程在进入锁前会在stack上创建一份内存空间,我们称之为Lock record),Lock record的owner指针指向对象的MarkWord。之后唤起被挂起的线程T1.如果到达全局安全点时T1已经结束,则对象并没有被锁定,这时将会将对象设置为未被锁定,且不可偏向。以便T2要获取锁时直接进入轻量级锁路径。

轻量级锁 前文中也有描述,线程在执行同步快之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间。

加锁

在当前对象的状态是无锁状态时(01)且不可偏向时

  1. JVM会将对象头中的MarkWord复制到Lock record中,这里称之为DMW;
  2. 尝试使用CAS将对象头中的MarkWord的前30位替换为指向锁记录的指针,并将Lock record中的owner指针指向MarkWord。这里CAS过程中的旧值为对象复制到Lock record中的值
  3. 如果2成功,那么这个线程获得了该对象的锁,并将对象MarkWord中的锁标志位设置为00(MarkWord最后两位)
  4. 如果2失败,也即当前MarkWord中的值与复制到当前线程的值并不想同,表示有其他线程已经将MarkWord值替换掉。所以这时给当前线程一定机会自旋(继续CAS替换),如果在自旋过程中已获得锁的线程结束并释放锁,则当前线程则可以进行加锁并执行。如果自旋失败,则将锁膨胀为重量级锁,具体的操作是将锁标志状态值变为(10),MarkWord中存储指向重量级锁(互斥量)的指针,后面再有想要获取锁的线程将会直接进入阻塞状态。当然当前线程也会进入阻塞状态。

释放

继上文加锁过程后,持有轻量级锁的线程在解锁时,会使用CAS操作将DMW替换回对象头,如果按照上文情况有其他线程竞争,导致对象的MarkWord中指针指向了互斥量,而当前线程需要使用CAS(以MarkWord中的指向Lock record的指针作为旧值,Lock record中的MarkWord副本为新值)将MarkWord替换回去。所以CAS必定是失败的。这时在当前线程释放锁后,JVM会唤醒阻塞中的线程

等待通知机制

等待/通知的经典范式 等待方

  1. 获取对象的锁
  2. 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件
  3. 条件满足执行对应逻辑
 synchronized(obj){
    while(条件不满足){
       obj.wait();
    }
    对应的处理逻辑
}

通知方

  1. 获取对象锁
  2. 改变条件
  3. 通知所有等待在对象上的线程
	synchronized(obj){
       改变条件
       obj.notifyAll();
    }

具体的实现方式是,在对象已有一个同步队列的情况下,再为对象维护一个等待队列,调用wait()方法的线程将会被放入等待队列。当通知方的线程调用notifyAll后会将等待队列中的线程移动到同步队列中。这样所有线程就可以再同步获取锁从而执行对应的处理逻辑。

1、Java并发容器和框架

1.1 ConcurrentHashMap

使用锁分段技术,首先将数据分成一段一段的存储,然后给每一段配一把锁,当一个线程占用访问其中一个段的数据时,其他段的数据也能被其他线程访问。内部实现的使用的是ReentrantLock

1.2 ConcurrentLinkedQueue

使用非阻塞方式(CAS)实现队头队尾的修改,但是对头和队尾的指针并不是真正的指向队头或队尾,可能指向后一个或前一个。以队尾tail为例,如果tail指向的是队尾,即next没有值,则直接将新元素添加到队尾。如果tail后面有值,即next不为null,则移动tail到队尾,插入新值后将tail指向队尾。这样操作可以减少修改过程中执行的CAS操作的次数

1.3 阻塞队列

  • ArrayBlockingQueue 由数组结构组成的有界阻塞队列
  • LinkedBlockingQueue 由链表结构组成的有界阻塞队列
  • PriorityBlockingQueue 支持优先级排序的无界阻塞队列
  • DelayQueue 使用优先级队列实现的无界阻塞队列
  • SynchronousQueue 不存储元素的阻塞队列
  • LinkedTransferQueue 由链表组成的无界阻塞队列
  • LinkedBlokingQueue 链表结构组成的双向阻塞队列 实现原理: 通知模式

1.4 Fork/Join框架

其思想是将大任务分割(Fork)成小任务,执行完毕后再Join出最终的结果。实现原理是维持一个ForkJoinPool,在ForkJoinTask中可以通过fork操作将task分成若干个ForkJoinTask,ForkJoinPool中有一个数组用来存储这些Task,此外Pool中还有一个ForkJoinWorkerThread数组。其中的线程负责执行这些任务。

2、Netty相关知识

  • reactor 模式
  • NIO selector模型
  • 内存池 复用
  • 零拷贝 DirectBuffer 相关链接

Netty线程模型 Netty高性能之道