背景:
不管是做Java开发、C开发、IOS开发还是Android开发,都会遇到多线程开发的需求场景。那么问题就来了,多线程开发时无法避免的就要考虑线程安全和线程通信的问题。今天我们主要讲讲线程安全问题,Android中可能大家用synchronized关键字用多了、久了就对锁的概念缺乏了认知,所以这里就简单回顾补充一下这方面知识。
一、线程并发访问的问题
程序运行时JVM会将我们的内存分成线程私有和线程公有两大块,这个在之前的内存优化篇章里面也有详细的介绍过,这里就不再啰嗦了。那么当多个线程访问并操作修改公有内存中的同一个变量时就会出现冲突的情况(这就是多线程并发访问的问题)。而Android中我们常用的两种解决方式就是synchronized和ThreadLocal,当然了,解决这个问题的方案还有很多,这里只是通关这两个来引申一下锁和变量副本的概念。
synchronized:是利用锁的机制,使变量或代码块在某一个时刻只能有一个线程访问。
ThreadLocal:是为每一个线程提供了变量的副本,使得每个线程在某一个时刻访问到的都是自身的变量副本(即副本只供自身使用,线程数据私有化),但是这样就隔离了多个线程对同一个公有内存变量的数据共享。
这里要简单讲一下ThreadLocal是如何保证线程数据私有化的。这要从它的get/set方法说起
通过源码很清楚的看到,ThreadLocal在set或者get的过程中都是通过当前线程的ThreadLocalMap变量来存取数据的。而这个ThreadLocalMap就是个Key/Value的Map数据结构,只不过它和普通Map较大区别就是它的Key只能是ThreadLocal对象。这就奠定了Thread与ThreadLocal的绑定关系,当有数据存取操作时只会在当前线程的ThreadLocalMap(线程私有空间)中进行,从而实现了线程之前数据的隔离和确保线程数据的私有化。
二、锁的类型
在Java中锁的类型要看面对的场景来划分:
这里只简单介绍下乐观锁和悲观锁
乐观锁:乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在自己操作的期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作等会我们会单独讲。
悲观锁:悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人在自己操作期间会修改数据,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会阻塞等待(block),直到拿到锁。Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。但是线程阻塞到唤醒之前的切换会消耗大量的系统资源,这也是它最大的缺点。
三、锁的状态
锁的状态可以分为如下几种:
- 无锁状态:顾名思义,就是没有加锁的状态
- 偏向锁:顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的(CAS都不会触发),这种情况下,就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
- 轻量级锁:轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁
- 重量级锁:同样重量级锁是从轻量锁升级而来,当线程在轻量锁下执行CAS操作失败时,轻量锁就会升级为重量锁。
这几个锁的状态之前存在一个升级的关系,如下图,而且锁只会升级不会降级。其中偏向锁和轻量级锁基本属于乐观锁类型。
四、CAS是个嘛
上面的乐观锁不停的提到CAS操作,面试的时候也是经常被问题。那CAS到底是个嘛?网上讲了一大堆的MarkWork相关的操作和知识,但是都比较晦涩难懂。所以我觉得还是按照自己的方式来理解比较好:上面也说了轻量级锁在读阶段不加锁,只在写阶段加锁并进行CAS操作,而CAS操作就是为了判断变量在当前线程操作期间是否有被其他线程进行修改。
4.1、CAS操作的流程
CAS有三个操作数:
- 内存值V:公有内存中变量值
- 旧的预期值A:内存值V在线程私有内存中的备份
- 要修改的新值B:线程操作后要修改的新值
CAS判断变量是否有被改动流程:
- 备份内存值V,也就是是预期值A
- 在预期值A的基础上操作,获取新值B。
- 取内存值V与预期值A进行比较
- 相等则新值B生效,否则重复1操作。
举个例子:
1. 在内存地址V当中,存储着值为10的变量。
2. 此时线程1想把变量的值增加1.对线程1来说,旧的预期值A=10,要修改的新值B=11.
3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
4. 线程1开始提交更新,首先进行A和地址V的实际值比较,发现A不等于V的实际值,提交失败。
5、线程1 重新获取内存地址V的当前值,并重新计算想要修改的值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。
6. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行比较,发现A和地址V的实际值是相等的。
7. 线程1进行交换,把地址V的值替换为B(B值生效),也就是12,
我想讲到这里,对什么是CAS操作基本有了清晰的思路了。
4.2、CAS操作的ABA问题
理解了CAS操作的流程,那问题随之也就来了,比较经典的就是ABA问题了。什么是ABA问题,还是拿上面的例子来说:
1、当线程1在提交之前,线程2快速的将地址V的值从10变成11,然后又从11变成10(10->11->10)。
2、这时候当线程1进行CAS判断时,发现V=A,认为变量没有被修改过,于是B值生效。但是实际上在这个过程中地址V的值被修改过,这就是著名的ABA问题。
既然有问题,那就得解决,而解决方案就是通过原子操作来实现,即在CAS的基础上增加一个状态标志(版本号控制)来标识变量是否被修改过。比如线程在修改变量时要带上版本戳,进行CAS操作的同时判断版本戳(实际上就是上面的MarkWork的作用,每次修改都会将版本戳记录到MarkWork空间)。所以JDK为我们提供了大量的Aomic原子操作类来解决这个问题。
五、死锁问题
既然讲到锁,那必然少不了面试官最喜欢的死锁问题。
5.1、产生死锁的原因
前面我们说了,多线程并发过程中加锁,那线程之间就会去争夺锁资源。而在争夺的过程中最容易发生的就是死锁问题的出现。而产生死锁就必须具备下面的四个条件:
- 互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。如:锁A一段时间内只能被一个进程A持有使用,进程B来申请锁只能等待进程A使用完释放锁A。
- 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
- 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
- 循环等待条件: 若干进程间形成首尾相接循环等待资源的关系
这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。如:线程B持有锁B不释放,线程A持有锁A不释放,现在线程B申请锁A进入等待,线程A申请锁B进入等待,死锁发生。
5.2、解决和避免死锁的方法
既然产生死锁存在四个必要条件,那么解决和避免死锁的方法就是想办法破坏这四个条件的成立。
- 让这四个必要条件不成立
- 确定资源的合理分配算法,避免进程永久占据系统资源
- 防止进程在处于等待状态的情况下占用资源
- 对资源的分配要给予合理的规划。