并发(锁)概要整理
线程能够在我们项目开发过程中,提供并行处理的能力。尤其是在一些耗时操作上,避免UI主线程的阻塞导致ANR。但在实际使用的过程中,我们也会遇到一些线程问题,例如线程安全,资源占用等问题,在解决这些并发问题上,我们可以充分利用锁这个机制。
概述
锁能够有效的避免一些线程冲突,但为了解决各类问题情景我们也衍生出了各种类型的锁,比如自旋锁,互斥锁、递归锁等等,我们就Android中常用的几种锁来展开理理对应Android来说的一些线程并发问题。
常用关键字
在Android中我们常用以下两种关键字来进行线程锁的实现。
锁名 | 作用域 | 锁类型 | 性质 |
---|---|---|---|
Synchronized | 代码块 | 独占锁/排他锁 | 同一个线程执行完之后别的线程才能继续执行,这就是通常说说的 原子性 (线程执行多条指令不被中断) |
Volatile | 单一变量 | 非阻塞算法(也就是不排他) | 当遇到三行 CPU 指令自然就不能保证别的线程不插足了,这就是通常所说的,volatile 能保证内存可见性,但是不能保证原子性 |
区别最多的原因是 volatile 禁止了指令重排(cpu内部指令重排序) 而Synchronized没有
ReentrantLock
1、由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:
- 等待可中断
持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。通过lock.lockInterruptibly()来实现这个机制。 - 公平锁
多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。 - 锁绑定多个条件
一个ReentrantLock对象可以同时绑定对个对象。ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
final
只要一个不可变的对象被正确的构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最直接,最纯粹的。
线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象时线程安全的。
并发三大特性
-
可见性
当一个线程修改了共享变量的值,其他线程能够看到修改的值,Java内存模型是通过在变量修改后将新值同步会主内存,在变量读取前从主内存刷新值,这种依赖主内存作为传递媒介的方法实现可见性
可以通过以下方案解决
1、通过 volatile 关键字保证可见性。
2、通过 内存屏障保证可见性。
3、通过 synchronized 关键字保证可见性。
4、通过 Lock保证可见性。
5、通过 final 关键字保证可见性 -
有序性
即程序执行的顺序按照代码的先后顺序执行,JVM存在指令重排,所以存在有序性问题,其实不止是JVM,CPU指令优化器也会重排
可以通过以下方案解决
1、通过 volatile 关键字保证有序性
2、通过内存屏障保证有序性
3、通过synchronized关键字保证有序性
4、通过Lock保证有序性 -
原子性
一个或多个操作,要么全部执行且执行过程中不被任何因素打扰,要么全部不执行。在Java中,对基本数据类型变量的读取、赋值操作都是原子性的(64位处理器,32位处理器会分段)。不采取任何的原子性保障措施的自增操作并不是原子性的。
可以通过以下方案解决
1、通过synchronized关键字保证原子性
2、通过Lock保证原子性
3、通过Lock保证原子性
锁的升级
synchronized锁的4种状态
synchronized锁有无锁、偏向锁、轻量级锁和重量级锁4种状态,在对象头的Mark Word里有展示,锁状态不同,Mark Word的结构也不同。
(1)无锁
很好理解,就是不存在竞争,线程没有获取synchronized锁的状态。
(2)偏向锁
即偏向第一个拿到锁的线程,锁会在对象头的Mark Word通过CAS(Compare And Swap)记录获得锁的线程id,同时将Mark Word里的锁状态置为偏向锁,是否为偏向锁的位也置为1,当下一次还是这个线程获取锁时就不需要通过CAS。
如果其他的线程尝试通过CAS获取锁(即想将对象头的Mark Word中的线程ID改成自己的)会获取失败,此时锁由偏向锁升级为轻量级锁。
(3)轻量级锁
JVM会给线程的栈帧中创建一个锁记录(Lock Record)的空间,将对象头的Mark Word拷贝到Lock Record中,并尝试通过CAS把原对象头的Mark Word中指向锁记录的指针指向当前线程中的锁记录,如果成功,表示线程拿到了锁。如果失败,则进行自旋(自旋锁),自旋超过一定次数时升级为重量级锁,这时该线程会被内核挂起。
(4)自旋锁
轻量级锁升级为重量级锁之前,线程执行monitorenter指令进入Monitor对象的EntryList队列,此时会通过自旋尝试获得锁,如果自旋次数超过了一定阈值(默认10),才会升级为重量级锁,等待线程被唤起。
线程等待唤起的过程涉及到Linux系统用户态和内核态的切换,这个过程是很消耗资源的,自选锁的引入正是为了解决这个问题,先不让线程立马进入阻塞状态,而是先给个机会自旋等待一下。
(5)重量级锁
在2中已经介绍,就是通常说的synchronized重量级锁。
锁升级过程
- 锁升级的顺序为:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,且锁升级的顺序是不可逆的。
线程第一次获取锁获时锁的状态为偏向锁,如果下次还是这个线程获取锁,则锁的状态不变,否则会升级为CAS轻量级锁;如果还有线程竞争获取锁,如果线程获取到了轻量级锁没啥事了,如果没获取到会自旋,自旋期间获取到了锁没啥事,超过了10次还没获取到锁,锁就升级为重量级的锁,此时如果其他线程没获取到重量级锁,就会被阻塞等待唤起,此时效率就低了。
锁降级
- 锁降级
当前线程获得写锁,没有释放写锁的情况下再去获得读锁,然后释放写锁,这个过程就是锁降级(当前线程持有锁的状态由写锁降到读锁就是锁降级) - 使用场景
当多线程情况下,更新完数据要立刻查询刚更新完的数据(因更新完数据释放写锁后还持有读锁,所有线程要获得写锁都要等待读锁释放,这时持有读锁的线程可以查到刚更新完的数据) - 弊端
适合读多写少的场景,如果锁降级的同时设置成了非公平锁可能会导致写锁很长时间获得不到
小知识
1、在32位机器上对long型变量进行加减操作是的安全隐患
非 volatile 类型的 long 和 double 型变量是 8 字节 64 位的, 32 位机器读或写这个变量的时候把它们分成两个 32 位操作,可能一个线程读取了某个值的高 32 位,低 32 位被另一个线程修改了。
所以 Java 官方推荐最好把 long/double 变量声明为 volatile 或是同步加锁 synchronized 以避免并发问题
2、ReentrantReadWriteLock支持锁降级,但是不支持锁升级
3、线程安全
- 线程安全,其实是内存安全,对事共享内存,可以被所有线程访问。
- 当多个线程访问一个对象,如果不需要额外的控制,调用的找个对象行为都是正确的,通常可以认为是线程安全的;
- 如果多个线程访问一个对象,每个线程所修改的内容都会覆盖其他线程产生的数据,且对象行为不正确,通常可以认为是非线程安全的;