Android 开发多线程之换个视角理解

Posted 冬天的毛毛雨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 开发多线程之换个视角理解相关的知识,希望对你有一定的参考价值。

写在前面

理解多线程并发和锁的关键在于正确的理清当前代码正处在哪个线程的执行环境下。说白了,这个同步代码/同步方法谁都可以来执行的,关键是有没有其他人在用这个锁。

1 Thread如何理解

1.1 Thread类与普通类的区别?

线程与线程类是不同的概念。 线程是系统CPU资源调度的基本单元,它是一个抽象的概念。Thread类和其他别的类没有什么区别。它只是对线程有着“管理”作用。而真正执行的代码都是在run()方法中,或者外部传入的runnable中。

1.2 中介作用

在线程的start方法之前,所有代码都在老线程上运行,调用start方法之后,内部会调用VMTthread.create,实际上真正在新线程上运行的只有run方法。这个角度理解,thread类只是一个中介,任务就是启动一个新线程来运行用户指定的runnable,而不关心是内部的还是外部传入的。

2 线程的状态

  1. New 线程创建
  2. Runnable 可执行状态
  3. Running 执行状态
  4. wait:当前线程调用某个对象object的wait方法,只有当子线程也同样调用该对象object的 notify/notifyAll方法才会唤醒当前线程。系统可能会有多个线程在wait,所以有notifyAll方法。
  5. block:遇到同步时候(同步方法、同步代码块、class类对象作为锁时,类中的静态方法),如果锁已经被其他的线程占用,那么就会进入阻塞状态,直到获取到锁。
  6. timed_wait: sleep/join, 等的一定时间才执行。
  7. terminated:线程运行完毕

join()用来保证两个线程的顺序执行:

Thread t1=new Thread(xx);
Thread t2=new Thread(xx);
t1.start();
t1.join();
t2.start();

上面代码表示,只有当t1执行完毕,t2才会执行。

2.1 wait和notify是如何绑定的?

通过同一个object对象。当一个线程调用某个object对象的wait方法时候,系统会在object中记录该请求,如果是多个线程调用则会有waitinglist,而当另外一个线程调用object的notify/notifyAll来唤醒一个/多个waitinglist中的线程。

2.2 线程调用wait()方法的条件?

  • 执行这个object的synchronized方法
  • 执行一段synchronized代码,且是基于这个object做的同步
  • 如果object是一个class类,可以实行synchronized static 方法

也就说: 一个线程获得了对象object锁lock,它才可以调用wait方法,而调用wait方法后该线程会释放锁,从而可以让别的线程来获取。

2.4 线程什么时候会释放锁?

  1. 当线程执行完毕
  2. 线程调用wait()方法

2.5 wait方法和sleep方法区别

  • wait方法必须要在同步代码中调用,sleep没有限制
  • wait方法会释放CPU、释放锁,sleep释放CPU,不释放锁(容易引起死锁)

3 Java内存模型的本质(重点)

JMM java内存管理模型,提出了主存和线程本身的工作内存概念,如图:

说明: 主存即内存。本地内存即CPU缓存(包括三级缓存、寄存器、WCbuffer等)。

一个单核CPU在一个线程上执行指令,如果需要切换线程它会把当前线程的执行现场保存到内存中去,方便后续恢复,然后清空PC计数器,加载新线程的指令地址。因此,单核CPU不存在同步的问题。

当多核CPU分别在执行自己线程指令时,如果存在共享同一个变量,那么就有可能存在竞争关系,因为每个CPU的核都有自己的本地缓存,而二者通信是通过内存中共享变量的方式来实现的。这就存在这个变量同步不及时的情况,所以需要同步。

其实本质上本地内存是一个抽象的概念。实际上指的是CPU中的L1、L2和寄存器等相关的缓存。 现在的设备都是多个CPU多核同时工作。如:CPU执行PC(程序计数器)中的某条指令需要一个变量,那么CPU不是直接去内存中操作该变量。而是先把变量读取到L3->L2->L1(三级缓存),最后到寄存器缓存起来,然后CPU在去寄存器中读取该变量。当然CPU不会一次只读取一个变量,而是一次读取一个缓存行Cache Line,一个缓存行的大小是64个Byte。如果在需要下一个变量则会先从寄存器和缓存中查找,这样就比去操作内存块很多。最后,把计算结果刷新到寄存器,同步到主存(也就是内存)中。

4 同步相关问题(重点)

4.1 重排序和happens-before

4.1.1 重排序

不管什么用到语言,我们写的代码最终都会转成汇编指令,而汇编指令与机器指令(如:01010)是一一对应的。因此当CPU在执行当前指令的时候处于读等待,CPU不工作了?岂不是浪费?为了提高性能,它会尝试下一条指令能不能先执行了?,如果可以,那么CPU就不会闲下来了。

但有个前提,这个指令跟前一个指令没有依赖关系才会执行。 有了乱序执行这个机制,一连串的指令就看起来变得可以并行执行了(其实没有,只是利用了CPU处于读等待的空隙做事情)。

因此,为了提高执行效率,编译器和CPU都会进行指令的重排序。

4.1.2 happens-before

顾名思义,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

不用太纠结这个概念。这只是一个规范而已,本质上就是说这个规范实现的代码肯定是加了内存屏障的。

如下这些代码实现方式,就是符合happens-before规则的:

  • 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作、
  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

4.2 synchronize

synchronize不管是修饰方法还是代码块,都需要用到锁对象。而锁对象的锁是有状态的,它会升级也会降级。锁的是对象,而不是把代码块锁住了!

锁的状态(jdk1.6后):

  • 无锁态
  • 偏向锁
  • 轻量级锁(也叫自旋锁)
  • 重量级锁 用户态向内核态申请锁,消耗锁资源。mutex

4.2.1 对象头

在jvm中,每个Object对象在内存中的布局有三部分组成:

  1. 对象头 : 包含markword(32位系统是4个字节,64位是8个字节)、class对象指针占4个字节
  2. 实例对象 :
  3. padding对齐: 为了让对象能够被8整除,需要补齐的字节数

举个例子: Object0=new Object(); o对象占多少内存?

假如是64位系统:

对象头+实例对象,也就是 8+4+0=12,不能被8整除,所以还需要加上padding 4. 因此,最终的o对象占用了16个字节。

请注意!!! markword就是用来存储锁信息的地方。 一共32/64位,多少位没有太大关系。我们只需要知道里面有什么即可。

4.2.2 锁的升级过程

  1. 一开始new 出锁对象时,还没有线程进入临界区,此时是无锁态。
  2. 有线程进入,则改成偏向锁,同时markword存入线程的id。
  3. 如果是同一线程则还是偏向锁。
  4. 当另外线程也进入临界区,请求锁对象,发现对象头已经有偏向锁了。产生了竞争!!那不好意思,先撤销偏向锁,然后两个线程通过CAS自旋的方式开始争抢锁对象,都往锁对象头里面写入自己线程栈的lock record。一旦有线程争抢成功,那么其他线程就会失败,此时锁对象变成了轻量级锁。
  5. 失败的线程会一直CAS循环下去,此间也可能还会有其他线程参与进来自旋。
  6. 当自旋次数超过一定值如10次,或者参与自旋的线程数太多。系统会进行干预。
  7. 这样干耗着会浪费CPU资源,所以干脆升级为重量级锁。其他线程全部进入mutex中的队列中去排队,线程进入wait或者block状态,不消耗CPU。
  8. 但synchronize修饰的是非公平的队列。

讲了这么多,synchronize的底层到底是怎么实现的?

其实还是 lock cmpxchg 指令。

4.3 volatile

volatile修饰变量后有两个作用: 1,内存可见性 线程间工作内存和主存实现了及时同步 2,防止指令重排 这对这个变量的操作被JMM加入内存屏障来保证指令不会乱序执行。

volatile到底是怎么解决指令重排的??

JVM层通过加入内存屏障,是一个逻辑实现,是jvm的要求规范而已,具体要看汇编语言。

  1. loadload 屏障 读
  2. storestore 屏障 写
  3. loadstore 屏障
  4. storeload 屏障

四个逻辑。 具体 就是在volatile读/写的前后加入内存屏障,保证顺序执行。内存屏障前后的指令不能重排序!

汇编层面: 最终就是调用了 lock: andl 指令。表示在寄存器中加0操作。

为什么这条指令能实现内存可见和禁止指令重排序??

内存可见性: 该指令能够将当前处理器对应缓存内容刷新到内存,并且是其他处理器的缓存失效。 重排序: 该指令本身就是内存屏障,它前面的指令和后面的指令都不能重排序。

4.4 CAS和原子操作

4.4.1 乐观锁和悲观锁

  • 悲观锁: 在访问共享资源的时候总是认为别人会来抢,所以只要访问临界区就直接上锁。通俗讲就是因为怕被抢,所以无脑上锁。比如用synchronize来对临界区上锁。
  • 乐观锁: 在访问共享资源时候,认为别的线程不会来抢资源。所以是“无锁”状态。但是可以通过CAS来保证数据的安全。ReentrantLock互斥锁(互斥别人,不互斥自己)

4.4.2 CAS

CAS(compare and swap): 比较并且交换。 目的: 在没有锁的状态下,可以保证多个线程对一个值的更新。

CAS实现思想:

  • E:拿到变量当前原始值(期望值)
  • V:计算的结果值
  • N:再次获取变量的值(当前值)

举个例子: 假设i=0,对i做++操作。 CAS的过程是这样的:

  1. 拿当i的前值 E=0;
  2. 计算结果值V=1;
  3. 再次拿i的当前,有可能如下情况: N=0;N=3(因为某个线程更改了)
  4. 如果E=N,表示没有被修改,我们可以更新,直接修改i=1。
  5. 如果E!=N,表示被修改过,我们这次修改就不能执行。然后我们在重头开始,再次比较,最终实现交换。

流程图:

- 实现的本质:

AtomicInteger内部就是通过CAS的方式来保证线程安全的。

内部会调用UnSafe类的方法。

private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();

/**
 * Atomically decrements by one the current value.
 *
 * @return the previous value
 */
public final int getAndDecrement() {
      //U表示 UnSafe类
    return U.getAndAddInt(this, VALUE, -1);
}

UnSafe类直接调用的是C++层的native方法compareAndSwapInt()

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
        // while中 native 方法
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

// native 方法
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

通过源码发现compareAndSwapInt()最终会调用c++层的 Atomic::cmpxchg方法,有如下指令:

//_asm_  表示汇编指令
//LOCK_IF_MP: 如果是multi-processs 多处理器, 现在处理器都是多CPU的。
_asm_ volatile (LOCK_IF_MP(%4) "cmpxchg1 %1, (%3)")...//后面省略

如果是多个处理器则 进行lock。为什么? 多个CPU就会出现多线程同时执行,出现并发问题。

而CAS方法会直接通过汇编指令:lock cmpxchg 指令 来完成CAS真正的操作。 cmpxchg 这个指令也就体现了比较和交换的本质了。

所以, 现在的问题变成 lock cmpxhg 指令 是如何实现线程安全的? 写入的过程是没有原子性保证的。 由 lock 指令来保证原子性。 最终由硬件来支持,硬件怎么实现的啊? 好啦,到这里就可以啦。 硬件通过锁定北桥信号?(我也不清楚了啊)。

4.5 AQS(AbstractQueuedSynchronizer) 抽象队列同步器

高并发编程的核心: AQS。

里面通过维护一个volatile int state变量和一个存储线程的队列(双向链表)来实现同步的。 它本身是一个抽象的类,定义了同步模板方法。具体逻辑需要子类去继承实现。 可通过构造方法传入是否是公平锁。

线程通过CAS去获取state值,state初始值为0,那么拿到锁state=1。 后续如果再有线程进来,那么就封装成Node节点看,然后放到队列中去阻塞,知道之前的线程释放锁。 支持公平锁和非公平锁两种方式。

4.5.1 ReentrantLock和synchronize的区别?

synchronize: 最终要通过用户态到内核态的切换,但是有锁的升级优化。悲观锁

ReentrantLock(jdk1.5后新增的锁): 基于AQS同步机制,其实内部还是通过CAS来获取锁,不用到内核态,轻量级。更加灵活。属于 乐观锁。 需要自己手动try catch,在finally中释放锁。

分享

小编学习提升时,顺带从网上收集整理了一些 android 开发相关的学习文档、面试题、Android 核心笔记等等文档,希望能帮助到大家学习提升,如有需要参考的可以直接去我 CodeChina地址:https://codechina.csdn.net/u012165769/Android-T3 访问查阅。

以上是关于Android 开发多线程之换个视角理解的主要内容,如果未能解决你的问题,请参考以下文章

android之换肤原理解读

多个用户访问同一段代码

Android 多线程下载,断点续传,线程池

多个请求是多线程吗

Android 性能优化:多线程注

Android开发之路-多线程