安卓开发笔记 之 锁机制
线程安全需要保证几个基本特性:
- 原子性 ,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
- 内存可见性 ,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
- 指令重排序 ,是保证线程内串行语义,避免指令重排等。
Atomic (Java 中的原子性操作)
实现原理: CAS + volatile 和 native 方法
原子更新的基本类型
Atomic 包提供了以下 4 个类 :
- AtomicIntegerArray :原子更新整型数组里的元素
- AtomicBoolean :原子更新布尔类型;
- AtomicInteger :原子更新整型;
- AtomicLong :原子更新长整型。
以原子方式将输入的数值与实例中的值(AtomicInteger 里的 value)相加,并返回结果。
- int addAndGet(int delta)如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。
- boolean compareAndSet(int expect,int update)以原子方式将当前值加 1,注意,这里返回的是自增前的值。
- int getAndIncrement() 最终会设置成 newValue,使用 lazySet 设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
- void lazySet(int newValue)以原子方式设置为 newValue 的值,并返回旧值。
- int getAndSet(int newValue)
原子更新数组
Atomic 包提供了以下 4 个类 :
- AtomicIntegerArray :原子更新整型数组里的元素;
- AtomicLongArray :原子更新长整型数组里的元素;
- AtomicReferenceArray :原子更新引用类型数组里的元素;
- AtomicIntegerArray :类主要是提供原子的方式更新数组里的整型
// 以原子方式将输入值与数组中索引 i 的元素相加
int addAndGet(int i,int delta)
// 如果当前值等于预期值,则以原子方式将数组位置 i 的元素设置成 update 值
boolean compareAndSet(int i,int expect,int update)
原子更新引用类型
原子更新基本类型的 AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类 Atomic 包提供了以下 3 个类:AtomicReference:原子更新引用类型,AtomicReferenceFieldUpdater:原子更新引用类型里的字段 ,AtomicMarkableReference :原子更新带有标记位的引用类型。
主要可以用来处理 ABA 的这种情况
原子更新字段类
如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic 包提供了以下 3 个类进行原子字段更新:AtomicIntegerFieldUpdater:原子更新整型的字段的更新器,AtomicLongFieldUpdater:原子更新长整型字段的更新器,AtomicStampedReference:原子更新带有版本号的引用类型。
详细使用的《Java 特种兵》264 页
并发常用的工具类
- CountDownLatch :想要实现多个线程同时去执行某些动作,CountDownLatch 是等待一个信号量,可以为这个信号量设定一个数字,每个达到指定目标的线程会给信号量的值叠加 1,当信号量的值达到设定的数字时,等待信号量的线程就被激活了。 使用例子
- Cyclicbarrier :想要按组按循序去进行执行某些操作,比如,想要 5 个线程都完成了某些任务,再去进行下一组任务。 使用例子
- Semaphor :用来设定一个数字,当请求数量达到指定的数字时,就将请求拦截在门外面,如果有线程释放资源,则会放一个请求进去。 使用例子
Synchronized
- synchronized 是 Java 内建的同步机制,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里.
- 反编译之后可以发现,它利用了 monitorenter/monitorexit 对实现了同步的语义:
11: astore_1 12: monitorenter 13: aload_0 14: dup 15: getfield #2 // Field sharedState:I 18: dup_x1 … 56: monitorexit
- 使用 synchronized 修饰 静态方法 时,其等同于利用下面代码将方法体囊括进来:
synchronized (ClassName.class) {}
- 不公平锁
- synchronized 不能中断锁
- 内部机制:
- 当一个对象获得锁,该对象的对象头里的 mark word 会被修改
- 线程计数器+1,之所以这样是为了让同一线程重复获取同一个锁(重入)。
- 写入该线程 ID
- 对象头中的标记字段(mark word):它的最后两位便被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 则跟垃圾回收算法的标记有关。
- 当进行加锁操作时,Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。
- 然后,Java 虚拟机会尝试用 CAS(compare-and-swap)操作替换锁对象的标记字段。
ReentrantLock
- ReentrantLock,通常翻译为再入锁,是 Java 5 提供的锁实现,它的语义和 synchronized 基本相同。再入锁通过代码直接调用 lock() 方法获取,代码书写也更加灵活。与此同时,ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制,比如可以控制 fairness,也就是公平性,或者利用定义条件等。但是,编码中也需要注意,必须要明确调用 unlock() 方法释放,不然就会一直持有该锁。
- 这里所谓的公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间最久的线程。公平性是减少线程“饥饿”(个别线程长期等待锁,但始终无法获取)情况发生的一个办法。
ReentrantLock fairLock = new ReentrantLock(true);
- 这样使用:
ReentrantLock fairLock = new ReentrantLock(true); fairLock.lock(); try { // do something } finally { // 这里就像数据库游标、流的关闭操作一样 fairLock.unlock(); }
- ReentrantLock 内部的 Sync 继承自 AQS。加锁的时候通过 CAS,将线程对象放到一个双向链表中,然后每次取出链表中的头节点,看这个节点的线程 ID 是否和当前线程相等。
Condition
- Condition 则是将 wait、notify、notifyAll 等操作转化为相应的对象,将复杂而晦涩的同步操作转变为直观可控的对象行为
- 通过 signal/await 的组合,完成了条件判断和通知等待线程,非常顺畅就完成了状态流转。注意,signal 和 await 成对调用非常重要,不然假设只有 await 动作,线程会一直等待直到被打断(interrupt),详细见 ArrayBlockingQueue 源码的 take() 和 enqueue()。
比较
用法比较
Lock 使用起来比较灵活,但是必须有释放锁的配合动作
Lock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁
Lock 只适用于代码块锁,而 synchronized 可用于修饰方法、代码块等
特性比较
ReentrantLock 的优势体现在:
具备尝试非阻塞地获取锁的特性:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁
能被中断地获取锁的特性:与 synchronized 不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
超时获取锁的特性:在指定的时间范围内获取锁;如果截止时间到了仍然无法获取锁,则返回
注意事项
在使用 ReentrantLock 类的时,一定要注意三点:
在 finally 中释放锁,目的是保证在获取锁之后,最终能够被释放
不要将获取锁的过程写在 try 块内,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故被释放。
ReentrantLock 提供了一个 newCondition 的方法,以便用户在同一锁的情况下可以根据不同的情况执行等待或唤醒的动作。
锁的升级降级
现代的(Oracle)JDK 中,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。
- 当没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作,在 对象头 上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。
- 如果有另外的线程试图锁定某个已经被偏向过的对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。
- 轻量锁的执行过程:
- 在代码进入同步块后, 如果该对象没有被锁定,虚拟机将首先在当前线程的栈帧里创建锁记录(lock record) 空间,用于存储锁对象 mark word 字段的拷贝(用于释放锁的时候再 copy 回去)
- 使用 CAS 将 mark word 更新为指向 lock record 的指针
- 如果 CAS 操作成功,线程便拥有了该对象的锁,即处于轻量锁状态
- 如果 CAS 操作失败,虚拟机会检查 mark word 是否已经指向了 lock record,如果是说明当前线程已经有了这把锁,可以直接进入同步块;否则升级为重量锁,重置 mark word 锁标志位为 10,线程阻塞
- 当 JVM 进入安全点(联想 GC) 的时候,会检查是否有闲置的 Monitor,然后试图进行降级。
自旋锁
- 自旋锁:竞争锁的失败的线程,并不会真实的在操作系统层面挂起等待,而是 JVM 会让线程做几个空循环(基于预测在不久的将来就能获得),在经过若干次循环后,如果可以获得锁,那么进入临界区,如果还不能获得锁,才会真实的将线程在操作系统层面进行挂起。
- 适用场景:自旋锁可以减少线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的代码块来说,有较大的性能提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。
如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,造成 cpu 的浪费。 - 单 cpu 无效,因为基于 cas 的轮询会占用 cpu,导致无法做线程切换
偏向锁
如果说轻量级锁针对的情况很乐观,那么接下来的偏向锁针对的情况则更加乐观:从始至终只有一个线程请求某一把锁。
具体来说,在线程进行加锁时,如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101。
在接下来的运行过程中,每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:最后三位是否为 101,是否包含当前线程的地址,以及 epoch 值是否和锁对象的类的 epoch 值相同。如果都满足,那么当前线程持有该偏向锁,可以直接返回。
内存屏障 (volatile 原理) 指令分为下列四类
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

上一篇: Android 安卓 UI 渲染优化
下一篇: MyBatis 介绍和使用
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论