彻底搞懂线程这一块,看这一篇就够了
前言
本系列详细讲解并发的知识,从基础到底层,让大家彻底搞懂线程和锁的原理,当然里面会涉及到一些内存结构的知识,所以如果为了更好地阅读效果,也可以先去看以下这两篇:
豁然开朗篇:安卓开发中关于内存那些事
豁然开朗篇:安卓开发中关于线程那些事(上篇)
当然如果已经对线程本身有了解的同学也可以先看该篇,因为本篇是对(上篇)的补充,锁原理的扩展知识对于以后要自定义锁会有很大帮助,以及线程池、klass等等该篇都会讲到。希望大家读完本文之后对线程有一个更深的理解,彻底能把线程、锁以及线程池等相关知识彻底搞懂。
提示:以下是本篇文章正文内容
一、锁原理
1.synchronized的缺点
经常使用synchronized应该都知道它是有一些不好的地方的:
(1)无法判断锁状态,不知当前线程是否锁住了,从java层上来讲是无法知道线程是否锁住了该对象。
(2)不可中断,如果线程一直获取不到锁,那么就会一直等待,直到占用锁的线程把锁释放。
(3)synchronized不是公平锁,新来的线程与等待的线程都有同样机会获得锁。其实大多数情况下,实际开发中都是要公平锁的。
(4)因为是关键字的方式实现获取锁和释放锁,导致中间过程不可控
(5)当升级为重量级锁后会造成用户空间切换到内核空间,资源会耗费较大。
2.并发三大方法
(1)synchronized关键字,原理是monitor机制(上篇已经讲过了)
(2)Atomic包,AtomicInteger,AtomicBoolean,AtomicLong等,原理是应用Volatile关键字和CAS理论实现并发
(3)Locks包,原理是应用CAS理论实现并发
3.Atomic
3.1单例模式用Volatile修饰
正常我们写一个单例模式应该会这样写:
public class SingleInstance {
private static SingleInstance instance;
private SingleInstance() {
}
public static SingleInstance getInstance() {
if (instance == null) {
synchronized (SingleInstance.class) {
if (instance == null) {
instance = new SingleInstance();
}
}
}
return instance;
}
}
这就是DCL懒汉式的写法,为什么要用两个if判断instance 是否为空呢?用一个if加上synchronized不行吗?比如写成这样:
public static SingleInstance getInstance(){
if(instance == null){
synchronized(SingleInstance.class){
instance = new SingleInstance();
}
}
return instance;
}
这样写的话,线程1拿到锁对象然后执行instance = new SingleInstance()完后继续往下走,而此时要释放锁,线程2这时已经执行到拿到synchronized(SingleInstance.class)这一行了,在等待锁对象的释放,然后线程1此时执行完instance = new SingleInstance()就释放锁,最后走return instance,紧接着线程2拿到锁后又执行一次instance = new SingleInstance(),这就等于又创建多一个SingleInstance()对象,不符合单例模式了。
因此要在线程2拿到锁后再加多一个判断,判断instance是否为空,为空的才去执行instance = new SingleInstance(),因此此时线程1已经返回了instance对象,所以线程2此时拿到锁后判断instance不为空,就不再执行,释放锁,也就不用再抢占锁资源了。这就是DCL懒汉式单例模式。不过它其实也有有可能会造成运行时异常的。
接下来先把该代码的getInstance()方法编译成dex文件指令,然后输出到一个txt文件里(可以看回豁然开朗篇:安卓开发中关于线程那些事(上篇),里面有dx工具的使用命令)看看getInstance()方法的dex指令是怎样的:
已经将一些无关紧要的指令省略掉了,如图所示就是getInstance()方法在dex文件里的指令,源码中ourInstance = new UserManger()对应的指令是000b行、000d行和0010三行指令,也就是说平时我们所写的构造对象代码,其实虚拟机执行的就是这三行指令:new-instance,invoke-direct,sput-object,只有经过这三个指令执行后,一个对象才能构造成功,然后被使用。
new-instance指令的作用是构造对象头和对齐数据(忘记对象在堆区里的结构的可以去看回豁然开朗篇:安卓开发中关于内存那些事):
然后invoke-direct则把类信息(也就是源码中定义的那些属性等)填充到实例数据区:
最后sput-object执行完后,就完成了变量引用指向该对象:
而这就有个问题,就是虚拟机在加载指令的时候,它是并发(多线程)执行的,这就会发生指令重排序,也就是在加载这三条指令时,不一定是按照顺序1-2-3来执行,有可能是1-3-2。当然不是所有代码都会发生重排序,只要当重排序后不会对原先代码发生的结果导致不一致的话,那就可以重排序:
1)a=1;b=a; 这是先写了一个变量,然后读取这个变量的值
2)a=1;b=1; 这是先写了一个变量,然后又写了一个变量
3)a=b;b=1; 这是读取了一个变量,然后写这个变量
以上这三种代码如果发生重排序后,结果肯定就跟原来不一样,所以它们不会发生指令重排序。而其他情况,比如我们的构造对象涉及的这三条指令就可以发生指令重排序了。
那么如果当new-instance,invoke-direct,sput-object这三条指令执行的顺序变成1-3-2执行,就代表当线程1执行完new-instance和sput-object指令后,该对象是被构造出来,而且并有变量引用指向,但此时是没有实例数据的,所以当线程2执行代码到 if(instance == null) 这一行的时候,认为该对象不为null,从而直接返回,那么它再使用该对象时它的实例数据都没有,肯定就会空指针异常了(虽然概率很低,但也不能忽视)。
所以为了避免发生这种问题的风险,就可以使用关键字volatile:
public class SingleInstance {
private volatile static SingleInstance instance;
private SingleInstance() {
}
public static SingleInstance getInstance() {
if (instance == null) {
synchronized (SingleInstance.class) {
if (instance == null) {
instance = new SingleInstance();
}
}
}
return instance;
}
}
用关键字volatile去修饰instance变量,这样SingleInstance()对象对于线程1和线程2来说是可见的,他们互相监听该对象,线程2监听到该对象还没被完全构造完,就会等线程1执行完第2步指令invoke-direct后才会去使用该对象。
所以volatile的作用能让对象公开化,这样线程A修改该对象后,会同步更新到另一个线程上。这是怎么做到的,之前在豁然开朗篇:安卓开发中关于线程那些事(上篇)说过,每个线程里有独立的高速缓冲区,一核代表一个线程,每个线程先在自己的缓冲区里把数据给CPU操作,如果别的线程要互相通信操作对方的数据,那只有线程1把自己缓冲区的数据复制到内存里,然后线程1再去内存里对内存这份数据进行读取:
而volatile的原理就是把该对象放到主内存里特意划分的内存屏障空间里,这块空间里的数据都被每个线程进行监听和实时更新:
修改volatile变量时会将修改后的值刷新到主内存中(里面的内存屏障区)去,因此此时其他线程它们自己的缓冲区里的变量值失效,因此当它们这些线程去读取该变量值时候就会重新去读取主内存中(里面的内存屏障区)刚刚更新的值。
但这种可见性也只是限定于原子性操作,就单纯的取值和赋值,像i++这种不是原子性操作的计算,用volatile修饰也没用,这点是要注意的,i++是复合操作,分三步的:
1)读取 i 的值
2)对 i 进行加1
3)将 i 的值写回到内存
中间是多了一步加1,所以volatile无法保证原子性,用代码实验一下:
public class LockRunnable implements Runnable{
private volatile static int a = 0;
private Object lockObject = new Object();
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
add();
}
}
private void add() {
a++;
}
public static void main(String[] args) {
LockRunnable lockRunnable1 = new LockRunnable();
LockRunnable lockRunnable2 = new LockRunnable();
Thread thread1 = new Thread(lockRunnable1);
Thread thread2 = new Thread(lockRunnable1);
thread1.start();
thread2.start();
try {
thread2.join();
thread1.join();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(a);
}
}
a变量用volatile去修饰,尽管这样,最终结果并不是我们预期的200000:
那么要让i++能并发起来不会有问题,而且又不会像synchronized那样有变成重量锁的风险,还有另一种方式,就是使用CAS。
4.CAS
CAS,Compare and Swap,比较并替换。CAS有三个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
当更新一个变量的值为新值的时候,只有当变量的预期值A和内存地址V中的实际值相同时,才会将内存地址V对应的值修改为新值B,下面来看使用了CAS这个例子:
线程1要更新值之前,线程2抢先一步将内存地址V中的变量值更新为2,然后线程1此时提交更新前会检查比较它自己的预期值A(1)和内存地址V中此时的实际值(已经被线程2更新为2)(因为CAS会使用volatile修饰变量所以能实时监听变量值),如果相同才会去更新刚刚自己的要更新的B值(2),很明显此时是不相同,所以不会进行修改,也就是修改失败。
当线程1下一次要修改值时,这次没其他线程抢先一步修改并更新,发现V中变量值跟A值是相同的,所以更新成功为2:
这就是CAS机制了,相信不难理解。
当然CAS也是有弊端的,那就是线程1是不知道线程2到底把这个变量值修改了多少次,假设线程2把值1修改成2后又修改成1,再更新,那线程1是不知道到底线程2有没有修改过这个变量值的,这就是ABA问题,可以使用AtomicStampedReference类解决,当修改过值后,因为该变量被AtomicStampedReference修饰,会记录一个时间戳的版本号,每次修改都会产生不同的版本号,从而识别出是否有修改过了。
public class Person {
private int age;
public static void main(String[] args) {
Person person = new Person();
person.age = 18;
AtomicReference<Person> atomicReference = new AtomicReference<>();
atomicReference.set(person);
}
}
5.ReentrantLock
它能让我们自己来控制锁,可以说是完美解决了synchronized不可控的缺点了:
public class LockRunnable implements Runnable{
private volatile static int a = 0;
private Object lockObject = new Object();
public static ReentrantLock lock = new ReentrantLock(true);
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
lock.lock();
try {
i++;
} catch (Exception e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
}
我们不用synchronized修饰,使用ReentrantLock锁,构造的时候传true参数表示公平锁,然后调用lock.lock()表示锁住以下要同步的代码块,然后lock.unlock()表示释放锁。
ReentrantLock的原理其实是使用LockSupport.park来休眠和LockSupport.unpark取消休眠,然后还使用了CAS来同步多线程竞争锁对象的情况,当然,还有等待队列(双向链表实现)来确定公平性等,如果我们自己来仿照ReentrantLock来自定义锁,可以按照这样的思路:
public class MyReentrantLock {
private AtomicBoolean isLock = new AtomicBoolean();
public Queue<Thread> waitThreads = new ConcurrentLinkedDeque<>();
public void lock() {
Thread currentThread = Thread.currentThread();
waitThreads.add(currentThread);
while (currentThread != waitThreads.peek()
|| !isLock.compareAndSet(false, true)) {
LockSupport.park(this);//当前线程休眠,处于waiting
}
waitThreads.remove();
}
public void unlock() {
isLock.set(false);
LockSupport.unpark(waitThreads.peek());
}
}
首先是定义了AtomicBoolean 类型的变量确保可见性和原子性,然后后面用它来调用compareAndSet()方法,这个方法就是使用了CAS,第一个参数表示旧的预期值A,第二个参数则表示要修改的新值B,这样isLock 变量不仅拥有了Atomic的优点,还使用CAS确保了线程同步,我们外部调用该锁也很方便:
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
myReentrantLock.lock();
try {
i++;
} catch (Exception e) {
e.printStackTrace();
}finally {
myReentrantLock.unlock();
}
}
}
跟ReentrantLock的调用一样。
6.线程池
说到并发怎么能不讲线程池呢,当一个线程start()之后,是在run方法里通过wait()、sleep()等方法来改变状态,而线程池是改不了线程的状态的,线程池的作用是用来管理这些线程何时开启start,还没开启的线程怎么排队,优先级是怎样的,线程池就是做这些工作。
下面来自定义一个线程池,首先定义一个接口,接口方法实现两个方法,加入Runnable对象和删除:
public interface IThreadPool {
void add(Runnable runnable);
void remove();
}
然后定义这个线程池:
public class ThreadPoolImpl implements IThreadPool{
private static int WORK_NUM = 2;//线程池内的工作线程数为2
private static PriorityBlockingQueue<Runnable> blockingQueue = new PriorityBlockingQueue<>();//线程队列
@Override
public void add(Runnable runnable) {
}
@Override
public void remove() {
}
}
首先实现IThreadPool接口,然后定义了一个固定线程(也就是该线程池里一直工作运行的线程)数量为2,还定义了一个权限队列(具有优先级PriorityBlockingQueue)blockingQueue 。接下来定义这个工作线程:
class ThreadPoolWorkerThread extends Thread{
private boolean isRunning = true;
@Override
public void run() {
while (isRunning) {
synchronized (ThreadPoolWorkerThread.class) {
try {
Runnable runnable = blockingQueue.take();
//取出线程后执行它的run逻辑
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
线程池里的工作线程是为了从队列里取出线程然后进行它们各自的run方法,而这个过程是需要同步,所以使用了synchronized锁(根据之前分析的锁机制可以根据实际情况来优化)。那什么时候来开启这些工作线程,当然是外部在构造线程池的时候开启这些工作线程,所以ThreadPoolImpl 的构造方法:
public ThreadPoolImpl() {
for (int i = 0; i < WORK_NUM; i++) {
ThreadPoolWorkerThread thread = new ThreadPoolWorkerThread();
thread.start();
}
}
当外部Activity构造ThreadPoolImpl对象的时候就构造出线程池内部两个工作线程然后让它们启动起来,别忘了线程池的添加线程方法:
@Override
public void add(Runnable runnable) {
//这里可以根据实际要求设置一个添加数据的限制
blockingQueue.add(runnable);
}
正如注释所说那样,如果实际需要线程池的队列数量有限制,可以在此之前判断一下然后才进行添加。基本上整个线程池的创建思路就是这些,另外一些优化和补充的功能和细节则要根据实际开发需求来定,最后外部Activity要使用该线程池则如下:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
IThreadPool threadPool = new ThreadPoolImpl();
for (int i = 0; i < 10; i++) {
threadPool.add(new Task("线程" + i));
}
}
static class Task implements Runnable{
private String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
}
}
}
代码不难,相信大家都能理解,在构造线程池的时候,就已经让它里面的工作线程启动,然后不断轮询队列里的线程,而队列里的线程则通过外部调用者来添加进去,删除线程的方法也是外部去操纵线程池的remove方法。另外有一点要说明一下,虽然在工作线程里不断轮询去从队列里取线程的时候会有阻塞(加了锁),但因为工作线程本身也是子线程,所以不会阻塞到主线程(UI线程)了。
整个关于线程讲解系列就总结到这里,更多有趣的文章可以关注本人公众号:Pingred
欢迎大家来学习与讨论