并发编程的问题引入
原子性
原子性这个问题,聊得最多的demo还是 i++
问题, i++
看起来是一步操作,其实做了两个运算,先加1再重新赋值给i;如果多个线程都会来操作这个 i++
操作的时候就很可能会出现线程 A 执行的时候 加了1但是还没赋值,然后B又来加了个1,最后A再运行赋值操作,这也就是天天挂在嘴边上的原子性的例子了,就不给demo举例了。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值,必须把这个值刷新到内存里面区让其他线程更新。
在Java中,synchronized关键字、Lock对象和volatile关键字都可以实现共享变量的可见性。关于可见性的问题,这涉及到硬件的内存模型。
有序性
volatile 关键字可以保证一定的有序性,也可以通过使用 synchronized
和 Lock
来保证有序性, synchronized
和 Lock
保证了某个时刻只有一个线程在执行同步代码,相当于是让线程顺序执行同步代码,保证了可见性。
为什么会出现有序性问题?因为指令重排序。指令重排序即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以和代码的顺序不一致,这个过程叫指令的重排序。
JVM 能根据处理器的特性对机器指令进行重排序,使机器指令的执行能更符合 CPU 的执行特性,最大限度的发挥机器性能。
happens-before
原则
Java 内存模型中存在一些先天的有序性,即不用通过任何手段就可以保证有序性,这个也通常称为 happens-before
原则。如果两个操作的执行次序不能通过 happens-before
原则推导出来,那么这俩操作的有序性就不能被保证到,虚拟机可以随意地对他们进行重排序。
- 程序次序规则: 一个线程内,按照代码的顺序,书写在前面的操作先行发生在书写在后面的操作
- 锁定规则: 一个锁的
unLock
操作先于后面对同一个锁的lock
操作 - volatile变量规则: 对一个变量的写操作先于后面对这个变量的读操作,如果一个线程先去写一个变量,然后一个线程去读,那么写入操作一定会先发生于读操作之前
- 传递规则: 如果 A 操作先发生于 B 操作,B 操作又先发生于 C 操作,那么 A 操作先发生于 C 操作
- 线程启动规则:
Thread
对象的start()
操作先发生于线程的每一个动作 - 线程中断规则: 对线程
interrupt()
方法的调用先发生于检测到中断事件发生 - 线程终结规则: 线程中的所有操作都先发生于线程的终止检测,我们可以通过
Thread.join()
方法结束,Thread.isAlive()
的返回值检测已经终止执行 - 对象终结规则: 一个对象初始化的完成先于它
finalize()
方法的开始
Java 中锁的种类
公平锁和非公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。
公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些;
非公平锁的好处是整体效率相对高一些,但是有些线程可能会饿死或者说很早就在等待锁,但要等很久才会获得锁。其中的原因是公平锁是严格按照请求所的顺序来排队获得锁的,而非公平锁时可以抢占的,即如果在某个时刻有线程需要获取锁,而这个时候刚好锁可用,那么这个线程会直接抢占,而这时阻塞在等待队列的线程则不会被唤醒。
公平锁可以使用 new ReentrantLock(true)
实现,无参构造方法 new ReentrantLock()
默认使用的是非公平锁。
自旋锁
- 内核态(Kernel Mode):运行操作系统程序
- 用户态(User Mode):运行用户程序
Java 中如果需要阻塞和唤醒一个线程,都需要操作系统帮忙完成,需要从用户态转换到核心态中,状态转换会耗费大量 CPU 时间,对于一些简单的同步代码块来说,状态切换消耗的时间可能比线程任务执行需要的时间都还要长。
如果某些同步代码块本来就只需要执行很少的一段时间,为了这段时间挂起和恢复现场不值得,如果是多核处理器,我们可以让后面请求那个锁的线程稍微等一下,正在执行的这个线程不放弃锁,为了让另一个线程等待,我们需要让这个线程执行一个忙循环,也就是自旋,也就是我们说的自旋锁的实现。
自旋等待不能代替阻塞。自旋等待虽然避免了线程切换的开销,但是如果等待时间过长,自旋锁也是浪费了很多处理器的资源,因此自旋锁的等待时间需要有一定的限度,如果自旋超过了限定次数(默认10次,可以使用 -XX:PreBlockSpin来更改)都还没拿到锁,就应该使用传统的方式挂起线程了。
真实的比喻
A,B两个人合租一套房子,共用一个厕所,那么这个厕所就是共享资源,且在任一时刻最多只能有一个人在使用。当厕所闲置时,谁来了都可以使用,当A使用时,就会关上厕所门,而B也要使用,但是急啊,就得在门外焦急地等待,急得团团转,是为“自旋”,这也是要求锁的持有时间尽量短的原因!
开启自旋锁
自旋锁在 JDK6 中默认开启,并且引入了自适应的自旋锁,意味着自旋的时间不再固定了,由前一次在同一个锁上的自旋时间以及锁的拥有者状态来决定。
自旋在轻量级锁中使用的,重量级锁中线程不适用自旋。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很可能再次成功,然后它可能会把自旋等待的时间设置的比上次更长。如果对于某个锁自旋很少成功获得过,那么以后获取这个锁可能不会再去自旋了。
自旋锁的特点
- 用于临界区互斥
- 任何时候至多只能有一个执行单元获得锁
- 要求持有锁的处理器所占用的时间尽可能短
- 等待锁的线程进入忙循环
锁消除
锁消除是在虚拟机 JIT 在运行时,对一些代码要求同步,但是被检测到又不可能存在共享数据竞争的关系,就会对锁进行消除,虽然这个数据可能在堆上,但是咱们也可以把它当成在栈上来看待。
比如下面的代码,sb 是 StringBuffer
类的实例,但是这是个局部变量,其他线程访问不到。我们知道 StringBuffer
的 append()
方法是个同步方法,这不就是多此一举了吗,用个 StringBuilder
不是美滋滋。。但是在即时编译之后,这段代码会忽略所有的同步。
1 | public String concatString(String s1, String s2, String s3){ |
锁粗化
原则上我们总是把临界区找到的足够精准,也就是同步块尽量的小,避免不需要的性能消耗。
举个案例,类似锁消除的 append()
方法。如果 StringBuffer sb = new StringBuffer();
定义在方法体之外,那么就会有线程竞争,但是每个 append()
操作都对同一个对象反复加锁解锁,那么虚拟机探测到有这样的情况的话,会把加锁同步的范围扩展到整个操作序列的外部,即扩展到第一个 append()
操作之前和最后一个append()
操作之后,这样的一个锁范围扩展的操作就称之为锁粗化。
可重入锁
可重入锁,指的就是同一个线程外层函数获得锁之后,内层递归函数仍然可以获取该锁,ReentrantLock
和 synchronized
都是可重入锁,最大的作用就是用来规避死锁。
类锁和对象锁
类锁:在方法上加上static synchronized的锁,或者synchronized(xxx.class)的锁。
对象锁: new 个对象用来当锁用
偏向锁、轻量级锁、重量级锁
synchronized
的偏向锁、轻量级锁和重量级锁都是通过 Java 对象头实现的。对象头可以分为 Mark Word
和类型指针klass。Mark Word
是关键,默认情况下,其存储对象的 HashCode
、分代年龄和锁标记位。
以 HotSpot
虚拟机为基准,看一下 Mark Word
的内容:
锁状态 | 存储内容 | 标志位 |
---|---|---|
无锁 | 对象的hashCode,对象的分代年龄,是否是偏向锁(0) | 01 |
轻量级锁 | 指向栈帧中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
GC标记 | (空) | 11 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
偏向锁是 JDK6 引入的一项锁优化,目的在于消除数据在无竞争情况下的同步原语,提高程序的性能。
偏向锁
偏向锁主要干啥事呢?偏向锁会偏向于第一个获得这个锁的线程,如果在后面的执行过程中,都是这个线程重复获得这个锁,那么这段代码好像永远也不需要同步。大多数情况下锁不存在线程竞争,而是由一个线程多次获得,为了让线程获得锁的代价更低就引入了偏向锁。
成功: 对象第一次被线程获取的时候,线程就是用 CAS 操作把这个锁的线程 ID 记录在 Mark Word
中,同时置偏向标志位为1,以后这个线程在进入和退出同步代码块都不需要进行 CAS 操作来进行加锁和解锁,只需要看一下对象头的 Mark Word
里面是不是存在指向当前线程的偏向锁,如果测试成功就表明获得了锁。
失败: 如果使用 CAS 操作失败表示这个锁存在多个线程的竞争关系,并且这个时候另一个线程获得偏向锁获得偏向锁的使用权,当到达全局安全点(safepoint,这个时间点上没有正在执行的字节码)时获得偏向锁的线程被挂起,膨胀为轻量级锁,同时被撤销偏向锁的线程继续往下执行同步代码。
当有另一个线程获取这个锁偏向模式就宣告结束了。
轻量级锁和重量级锁
偏向锁/轻量级锁/重量级锁这三种锁是指锁的状态,并且是针对synchronized。在Java 5通过引入锁升级的机制来实现高效 synchronized
。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
线程在执行同步块之前,JVM 会在当前线程的栈帧中创建用于存储锁记录的空间,并且将对象头 Mark Word
复制到锁记录中,然后线程尝试使用 CAS 将对象头中的 Mark Word
替换为指向锁记录的指针,如果成功线程获得锁,如果失败表示其他线程竞争锁,然后当前线程就以自旋来获取锁,如果自旋失败锁就会膨胀为重量级锁,如果自旋成功就还是轻量级锁。
轻量级锁的解锁过程也是通过 CAS 操作来完成的,如果对象的 Mark Word
仍然指向线程的锁记录,那就用CAS操作把对象当前的 Mark Word
和线程中赋值的 Displaced Mark Word
替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,就说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的(区别于偏向锁)。这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
整个 synchronized
锁流程如下:
- 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
- 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
- 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用CAS将对象头的
Mark Word
替换为锁记录指针,如果成功,当前线程获得锁 - 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级状态。
- 如果自旋失败,则升级为重量级锁。
悲观锁和乐观锁
悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
乐观锁:假定不会发生并发冲突,只在提交操作时检测是否违反数据完整性。(使用版本号或者时间戳来配合实现)
共享锁和独占锁
共享锁:如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排它锁。获准共享锁的事务只能读数据,不能修改数据。
独占锁:如果事务T对数据A加上排它锁后,则其他事务不能再对A加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。
对于 ReentrantLock
而言,其是独享锁。但是对于 Lock
的另一个实现类 ReadWriteLock
,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写、写读 、写写的过程是互斥的。独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。对于 synchronized
而言,当然是独享锁。
分段锁
分段锁是一种锁的设计,
读写锁
读写锁是一个资源能够被多个读线程访问,或者被一个写线程访问但不能同时存在读线程。Java当中的读写锁通过ReentrantReadWriteLock实现。
互斥锁
所谓互斥锁就是指一次最多只能有一个线程持有的锁。在 JDK 中 synchronized
和 JUC 的 Lock
就是互斥锁。
无锁
如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
- 无状态编程。无状态代码有一些共同的特征:不依赖于存储在对上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非无状态的方法等。可以参考Servlet。
- 线程本地存储。可以参考ThreadLocal
- volatile
- CAS
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
Java中锁的实现
Lock
是 JUC 包的顶层接口,ReentrantLock
的继承关系图如下:
ReentrantLock
对 Lock
接口的实现主要依赖了 Sync
, 而 Sync
继承了 AbstractQueuedSynchronizer(AQS)
,它是 JUC 包实现同步的基础工具,AQS 中定义了一个 private volatile int state;
作为共享资源,如果线程获取资源失败就会进入同步 FIFO 队列进行等待,如果成功获取资源就执行临界区代码。释放完资源时,会通知同步队列中的等待线程来获取资源后出队并执行。
AQS
AQS 是抽象类,内置自旋锁实现的同步队列,封装入队出队操作,提供了独占、共享、中断等特性的方法,AQS 的子类可以根据定义不同的资源实现不同性质的方法,许多同步类都依赖于它,比如常见的 ReentrantLock/Semaphore/CountDownLatch
。
AQS 维护了一个共享资源变量 volatile int state
和一个 FIFO 线程等待队列,state有如下三个操作方式:
1 | protected final int getState(); |
AQS定义了两种资源共享方式
- 独占:(Exclusive,只有一个线程能执行,比如
ReentrantLock
) - 共享: (Shared,多个线程可同时执行,比如
CountDownLatch/Semaphore
)
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively()
:该线程是否正在独占资源。只有用到condition才需要去实现它。tryAcquire(int)
:独占方式。尝试获取资源,成功则返回true,失败则返回false。tryRelease(int)
:独占方式。尝试释放资源,成功则返回true,失败则返回false。tryAcquireShared(int)
:共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。tryReleaseShared(int)
:共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
比如可重入锁 ReentrantLock
,定义为 state 为0的时候可以获取资源并置为1,若已经获得资源,A 线程 lock()
的时候会调用 tryAccquire()
独占该锁并将 state + 1
,后面其他线程 tryAccquire()
就会失败,知道 A 线程 unLock()
到 state = 0
为止,其他线程就可以获取这个锁了。A 线程是可以持续获得同一个锁的,每次都会让 state + 1
,这也就是可重如的概念。
CountDownLatch
初始化的时候就制定了资源的总量,state
会被初始化为 n,countDown()
方法不断地以 CAS 将 state 减 1,state 为 0 的时候获得锁,释放后 state 一直为 0,这个时候 await()
就不会继续阻塞了,所有 CountDownLatch
是一次性的,用完之后再想用就得重新创建一个,如果要循环使用可以考虑下 CyclicBarrier
。
一般来说自定义同步器要么是独占,要么是共享,所以以后一般实现一组 tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock
。
CAS
CAS(Compare And Swap)
是乐观锁技术,多个线程同时更新一个共享变量的时候,只有一个会成功,其他的都会失败,因为更新之前线程都会比较下当前共享变量的值还是不是之前获得的值,如果不是那么这次更新变量值就会失败,失败的线程并不会被挂起,而是知道这次更新失败并且可以再次进行尝试更新。
CAS操作中包含三个操作数
- 需要读写的内存位置(V)、
- 进行比较的预期原值(A)
- 拟写入的新值(B)
如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B,否则处理器不做任何操作。无论哪种情况,它都会在CAS 指令之前返回该位置的值(在CAS的一些特殊情况下将仅返回CAS是否成功,而不提取当前值)。
CAS有效地说明了:我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。这其实和乐观锁的冲突检查 + 数据更新的原理是一样的。
JUC 包就是建立在 CAS 之上的,相比同步阻塞, CAS 是一种更好的实现方式
比如 AtomicInteger
类,下面这个方法就是 JDK8 中 AtomicInteger
实现原子操作的实现方式
1 | public class AtomicInteger extends Number implements java.io.Serializable { |
Unsafe
类的加法方法
- var1 是atomicInteger对象
- var2 内存地址
- var4 新增的值
一个 do-while 循环判断如果替换失败就重试
1 | public final int getAndAddInt(Object var1, long var2, int var4) { |
可以看到 AtomicInteger
中使用到一个 Unsafe
类,这个 Unsafe
类可以像 C 语言一样直接操作内存指针,里面的所有方法都有一个 native
修饰符,也就是都是调用操作系统的本地方法。
valueOffset
是用来记录 value 本身在内存的偏移地址的,这个记录,也主要是为了在更新操作在内存中找到 value 的位置,方便比较,可以看到 AtomicInteger
的这个静态代码块。
1 | static { |
CAS 带来的问题 — ABA
ABA 问题:线程 X 获取到变量值为 A,线程 Y 也获取到变量值为 A,现在 线程 X 先将 变量的值改为 B,再将值改成 A,这个时候线程 Y 来执行发现变量的值是 A,就认为它没有变可以直接替换,这样认为对么?
ABA 问题如何 解决
用 AtomicStampedReference/AtomicMarkableReference
解决ABA问题
AtomicStampedReference
里面维护了一个版本戳 final int stamp
可以解决 ABA 问题。
AtomicMarkableReference
里面通过一个布尔值 final boolean mark
来记录是否已经被修改过。
Java 中锁的使用
synchronized
例如下面的代码:
1 | public class SynchronizedTest implements Runnable{ |
多运行几次会有不同的结果。比如下面这个结果,根据结果可以发现 get()
调用 set()
并没有被阻塞证明是可以重入的,并且 线程1最后运行,但是线程1比线程2先来啊,证明 synchronized
是一个非公平锁。
1 | 1 run thread name-->test-0 |
ReentrantLock
ReentrantLock
既可以是公平锁,也可以是非公平锁,因为它的内部抽象类 Sync
继承了 AbstractQueuedSynchronizer
,并且有两个具体实现。
lock()
: 获取锁unlock()
: 释放锁tryLock()
: 它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待tryLock(long time, TimeUnit unit)
: 方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。lockInterruptibly()
:lock.lockInterruptibly()
想获取某个锁时,假若此时线程 A 获取到了锁,而线程 B 只有在等待,那么对线程 B 调用threadB.interrupt()
方法能够中断线程B的等待过程。
非公平锁
ReentrantLock
默认的构造方法就是非公平锁
1 | public class NoFairReentrantLockTest implements Runnable{ |
输出结果如下,跟 synchronized
输出结果类似,可重入并且非公平,因为 test-1 不是第二个运行的
1 | 1 run thread name-->test-0 |
公平锁
下面这个代码实现的就是一个公平锁,会按照队列顺序来。
private Lock lock = new ReentrantLock(true);
Condition
Condition
是在 JDK1.5
中才出现的,它用来替代传统的 Object
的 wait()
、notify()
实现线程间的协作,相比使用 Object
的 wait()
、notify()
,使用 Condition
的 await()
、signal()
这种方式实现线程间协作更加安全和高效。
因此通常来说比较推荐使用 Condition
,Conditon
中的 await()
对应 Object
的 wait()
;Condition
中的 signal()
对应 Object
的 notify()
;Condition
中的 signalAll()
对应 Object
的 notifyAll()
;
ReentrantReadWriteLock
读写锁的性能都会比排它锁要好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。JUC 提供读写锁的实现是 ReentrantReadWriteLock
。
特性 | 说明 |
---|---|
公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平 |
重进入 | 该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁 |
锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁 |
里面一个读锁一个写锁,读写锁定义为:一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。