Android多线程知识,搞懂这些就足够了

95e93aeb3df1270abf7d17a8c4902c04.jpeg

/   今日科技快讯   /

近日,乐视视频官微宣布,2023年1月1日起,乐视将执行每周四天半工作制,每周三弹性工作半天,考勤时间调整为连续的5小时,比如上午10点至下午15点、上午11点至下午16点都是符合规定的。

/   作者简介   /

本篇文章来自Pingred_hjh的投稿,文章主要分享了Android中关于线程的相关知识,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

Pingred_hjh的博客地址:

https://blog.csdn.net/qq_39867049?type=blog

/   前言   /

本系列详细讲解并发的知识,从基础到底层,让大家彻底搞懂线程和锁的原理,当然里面会涉及到一些内存结构的知。

当然如果已经对线程本身有了解的同学也可以先看该篇:

这一次,把Android开发线程原理翻个底朝天

因为本篇是对之前文章的补充,锁原理的扩展知识对于以后要自定义锁会有很大帮助,以及线程池、klass等等该篇都会讲到。希望大家读完本文之后对线程有一个更深的理解,彻底能把线程、锁以及线程池等相关知识彻底搞懂。

/   锁原理   /

synchronized的缺点

经常使用synchronized应该都知道它是有一些不好的地方的:

(1)无法判断锁状态,不知当前线程是否锁住了,从java层上来讲是无法知道线程是否锁住了该对象。

(2)不可中断,如果线程一直获取不到锁,那么就会一直等待,直到占用锁的线程把锁释放。

(3)synchronized不是公平锁,新来的线程与等待的线程都有同样机会获得锁。其实大多数情况下,实际开发中都是要公平锁的。

(4)因为是关键字的方式实现获取锁和释放锁,导致中间过程不可控

(5)当升级为重量级锁后会造成用户空间切换到内核空间,资源会耗费较大。

并发三大方法

(1)synchronized关键字,原理是monitor机制(上篇已经讲过了)

(2)Atomic包,AtomicInteger,AtomicBoolean,AtomicLong等,原理是应用Volatile关键字和CAS理论实现并发

(3)Locks包,原理是应用CAS理论实现并发

Atomic

单例模式用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文件里,看看getInstance()方法的dex指令是怎样的:

2fccf18b0615cf7ea4072bf895ab83c8.jpeg

已经将一些无关紧要的指令省略掉了,如图所示就是getInstance()方法在dex文件里的指令,源码中ourInstance = new UserManger()对应的指令是000b行、000d行和0010三行指令,也就是说平时我们所写的构造对象代码,其实虚拟机执行的就是这三行指令:new-instance,invoke-direct,sput-object,只有经过这三个指令执行后,一个对象才能构造成功,然后被使用。

new-instance指令的作用是构造对象头和对齐数据:

3dee537696707741a0a7fabf2919affe.png

然后invoke-direct则把类信息(也就是源码中定义的那些属性等)填充到实例数据区:

4b057735d6311f32db4f9ec6bbce8412.png

最后sput-object执行完后,就完成了变量引用指向该对象:

2b58a9a2da4ff4a4415ed25c96f93a88.png

而这就有个问题,就是虚拟机在加载指令的时候,它是并发(多线程)执行的,这就会发生指令重排序,也就是在加载这三条指令时,不一定是按照顺序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再去内存里对内存这份数据进行读取:

e95fc9efecde7f72b8033fa7b4004488.png

而volatile的原理就是把该对象放到主内存里特意划分的内存屏障空间里,这块空间里的数据都被每个线程进行监听和实时更新:

b26eaa27460d2bcdd0c632e10f2433ca.png

修改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:

2dc90e320c990921af20313f277d4628.png

那么要让i++能并发起来不会有问题,而且又不会像synchronized那样有变成重量锁的风险,还有另一种方式,就是使用CAS。

CAS

CAS,Compare and Swap,比较并替换。CAS有三个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

当更新一个变量的值为新值的时候,只有当变量的预期值A和内存地址V中的实际值相同时,才会将内存地址V对应的值修改为新值B,下面来看使用了CAS这个例子:

a310a27768bde0d090e5c9c98ce94048.png

线程1要更新值之前,线程2抢先一步将内存地址V中的变量值更新为2,然后线程1此时提交更新前会检查比较它自己的预期值A(1)和内存地址V中此时的实际值(已经被线程2更新为2)(因为CAS会使用volatile修饰变量所以能实时监听变量值),如果相同才会去更新刚刚自己的要更新的B值(2),很明显此时是不相同,所以不会进行修改,也就是修改失败。

当线程1下一次要修改值时,这次没其他线程抢先一步修改并更新,发现V中变量值跟A值是相同的,所以更新成功为2:

9ec38dca92259b0ddeb35859734183f5.png

这就是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);
    }

}

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的调用一样。

线程池

说到并发怎么能不讲线程池呢,当一个线程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线程)了。

整个关于线程讲解系列就总结到这里,欢迎大家来学习与讨论。

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

Android开发中关于内存的那些事,一篇全搞懂

Kotlin | 这些隐藏的内存陷阱,你应该熟记于心

欢迎关注我的公众号

学习技术或投稿

b2134f18b9abe184484731348e54a771.png

fc4e2499dc1e01583a7e52076288a496.jpeg

长按上图,识别图中二维码即可关注

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值