0%

volatile综述

volatile主要保证两点

  1. 变量的值在多线程之间是可见的
  2. 阻止指令重排序,间接的保证变量值的可见性

但不保证原子性,所以开销比synchronized加锁要小

可见性的解释涉及到java内存模型,每个线程都有自己的私有内存,对应堆中的虚拟机栈中,当多个线程访问主存上同一个变量时,会拷贝变量到各自私有线程中,线程对变量做出修改都是在私有内存中的修改,线程并不知道各自的修改值,也就是所谓的不可见。

用volatile修饰变量后,对变量的修改会立刻刷新到主存,读volatile变量也会从主存去读取,保证可见性,但这让缓存失效,牺牲了一定的访问速度。

指令重排序有好几个层面的,有CPU层面和编译器层面的。

为什么要进行指令重排序呢?是为了提高CPU利用率。

CPU层面的指令重排序是因为防止CPU指令流水线的停顿,指令流水线是做什么的呢?一条指令可以分为多个执行步骤,每个执行步骤可以由多个功能单元的硬件来执行,这样就不用串行的执行指令了,可以让多个功能单元并行的执行指令的不同的步骤,多个指令的执行速度一下提升好几倍。但是如果指令之间存在数据依赖,流水线就只能停顿等待,但是如果把没有依赖的指令重排序一下,就不用停顿等待了,这样CPU利用率就提高了。

编译器的重排序要达到的目的也是提高CPU利用率。

检查编译器有没有做重排序,可以开两个线程做验证,给两个变量a1、a2赋初始值0,线程1执行两条语句,先给a1赋值1,然后打印a2,线程2也执行两条语句,先给a2赋值1,然后打印a1,正常执行结果是打印1和1,执行很多次过后会发现有一次打印出来的两个结果是10、01甚至00,打印语句被重排序到前面去先执行了。

既然指令重排序这么好,为什么还要阻止呢?

因为指令重排序虽然不会影响单线程的代码语义,但是在多线程下会影响程序运行的正确性,有时候需要手动阻止。这个可以写代码验证的。

volatile阻止指令重排序具体是通过内存屏障来实现的。

内存屏障分为读屏障和写屏障,读屏障将内存数据拷贝到缓存,写屏障将缓存数据刷新到内存。

volatile写操作之前插入StoreStore屏障,之后插入StoreLoad屏障

volatile写操作volatile读操作之后插入LoadLoad屏障和LoadStore屏障

内存屏障有两个作用:

  1. 阻止内存屏障两侧的指令重排序
  2. 强制把缓存数据回写到主内存,对所有线程可见

但也不能总是用volatile来保证有序性,那样太麻烦了。所有Java内存模型规定了一个happens-before原则,保证前一个操作的结果对后续操作是可见的,来帮助开发者辅助判断代码是否有线程安全的问题。

比如

  1. 一个线程内必须保持语义的串行,按照代码顺序执行
  2. 解锁操作要发生在对这个锁的加锁操作之前
  3. 线程start()方法先于线程的每个动作

在多线程中需要修改和读取的变量,不需要保证原子性的,但要保证可见性的,就要用volatile修饰

一个典型的使用场景是双重检查锁定的单例模式,对单例对象加volatile修饰,以防止new对象的时候发生指令重排序,进而导致得到了一个对象的引用但是对象还没有初始化。

volatile解决了什么问题?

  1. 保证变量的可见性,多线程可读取到变量最新修改值
  2. 保证变量的有序性,阻止指令重排序

可见性是什么意思?

首先要介绍Java内存模型,Java的数据都存储在主存中,每个线程读写主存的数据时,都会把数据拷贝到线程自己的工作内存中再进行读写,工作内存可以理解为线程自己的缓存,多个线程对同一个数据修改时,修改的都是线程自己工作内存的数据拷贝,多个线程的工作内存之间是非共享的,对数据做出修改后互相不可见。

volatile是如何保证可见性的?

多线程读写主存中存储的某个volatile变量时,会把这个变量所在的内存块都复制到线程的工作内存,当某个线程修改了工作内存中的volatile变量,会立即将新数据写入共享内存,其他线程的工作内存中这一缓存块会被标记失效,被要求重新从共享内存读取数据,保证读取到的是最新的值。

可以参考缓存一致性协议来理解。

为什么要有指令重排序,指令重排序有什么好处?

减少CPU空等,提高CPU整体的吞吐量。

编译器层面有编译器对指令顺序的优化。

CPU层面有指令流水线对指令执行的优化。

虚拟机是如何为volatile实现可见性和有序性?如何防止指令重排序?

通过内存屏障来实现。

内存屏障之前所有的写操作都要回写到主存,内存屏障之后的所有读操作都能获取内存屏障之前的所有写操作的最新结果,这样就保证了可见性。

因此也就禁止把内存屏障之后的指令重排序到内存屏障之前。

先简单了解两个指令:

  • Store:将处理器缓存的数据刷新到内存中。
  • Load:将内存存储的数据拷贝到处理器的缓存中。
屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

volatile为什么不能保证原子性?

因为一个volatile变量的操作,比如i++,本身多个字节码指令,并且每条指令也不一定是原子化的,所以volatile变量的操作也不可能是原子化的。

这个可以用程序验证,volatile变量初始值为0,两个线程分别对其自增一万次,最后变量的值是小于两万的。

可以从字节码指令看出来

如下的代码:

1
2
3
4
5
6
7
public class VolatileDemo {
private volatile int value = 200;

public void increament() {
value++;
}
}

编译后value++的字节码指令如下:

1
2
3
4
GETFIELD VolatileDemo.value : I
ICONST_1
IADD
PUTFIELD VolatileDemo.value : I

GETFIELD指令是获取对象的字段值,将值压入栈顶
ICONST_1指令是int型常量1入栈,因为要加1,所以就入栈1
IADD指令是弹出栈顶的两个数字进行相加,相加的和再压入栈
PUTFIELD指令是给对象的变量赋值栈顶的值

volatile可以保证GETFIELD时可以读取到value的最新值,但是iconst_1和add时,其他线程可能已经把value值也改变了,最后PUTFIELD是过期的值,所以多线程调用increament时,最终的值会变小。

一个volatile变量自增操作有4个指令,一条字节码指令也不一定是原子的,可能还会转换为若干条本地机器码指令,所以volatile不能保证原子性。

需要通过synchronized或java.util.concurrent包下的锁或原子类来保证原子性。

volatile使用案例

  1. volatile可以使得long和double的赋值是原子的
  2. 多线程需要实时读取的值,例如:
    a. AtomicInteger里的value都是volatile的
    b. AQS里的state变量也是volatile
  3. CopyOnWriteArrayList里的数组也是volatile的,保证数组拷贝过后,对其他读线程立即可见
  4. 阻止if或while中条件变量的重排序,防止出现不符合预期的结果。if或while语句内部的变量,跟条件变量的赋值,如果在同一个方法中执行,因为没有依赖关系,可能会发生重排序。比如方法a里会读取先通过if判断flag变量是否为true,为true就打印变量c的值,然后在方法b中,先对c赋值,再对flag赋值true,b方法中两个语句可能会重排序的,在多线程执行的时候,就会导致flag先为true,但是c还没赋值,另外一个线程已经执行方法a了,
  5. 双重检查锁定的单例

ThreadLocal作用

让不同的线程持有相同数据类型的不同的数据副本。

ThreadLocal使用场景

如果一个对象在多线程之间通过加锁竞争的共享,会引起较大的性能损失,且对象占用内存不是很大的情况下,就应该考虑用ThreadLocal为每个线程分配单独的对象。比如多线程下产生随机数,可以写代码打印耗时,验证这一点。

ThreadLocal实现原理简述

set(value)

每个线程都有一个ThreadLocalMap,set时会先取当前线程的ThreadLocalMap,以当前的ThreadLocal对象为key,存储value。

get()

从当前的线程的TheadLocalMap中取数据,key为ThreadLocal对象。

ThreadLocalMap是自定义的哈希表,用一个table数组来存储key value,key value被封装在一个Entry对象中,Entry对象是继承WeakReference,WeakReference引用了ThreadLocal对象,ThreadLocal对象没有别的地方引用的时候,在垃圾回收触发时就可以回收了,value是被Entry类强引用的,而Entry类是被ThreadLocalMap中的table数组强引用的,所以value有内存泄露的风险,特别是线程池的核心线程是会一直运行的,线程对象一直存在,ThreadLocalMap也就一直存在,Entry对象中value也就一直不会释放,所以ThreadLocal如果用完了,最好及时remove()掉。

如果没有手动remove清理,ThreadLocalMap也会在get()和set()的时候去清理掉过期的Entry对象,但如果你不调用set和get就不会回收了。特别的,在set()方法做清理的时候做了一个优化,会以对数级别的时间复杂度跳跃式的扫描整个数组,来去寻找过期的Entry对象,这样避免全表扫描,又保证垃圾不会堆积过多。

ThreadLocalMap也有扩容机制,当存储元素个数超过数组长度的2/3后,会把数组扩容为原来的2倍。

table数组的大小始终是2的次方,保持2的次方的大小,是为了计算索引时能够散列的比较均匀。

key value应该存储在数组的哪个索引位置是怎么计算的呢?

首先获取Key对象的hashCode,然后与数组容量-1这个数做与操作得到索引,数组容量是2的次方,减1后的数字的二进制低位全部都是1,跟key的hashCode做与操作,相当于截取了hashCode的低位,散列会比较均匀,如果数组容量不是2的次方,容量-1的这个数的二进制数在低位中就会有0,再与key的hashCode做与操作会导致元素堆积也就是是散列冲突。

在ThreadLocalMap中,key对象hashCode获取并不是调用ThreadLocal对象的hashCode方法,而是使用ThreadLocal中定义的一个静态变量的值,每当创建一个ThreadLocal对象时,这个静态变量就增加固定的值,这个固定的值很特殊,它是一个黄金分割数,带来的效果就是让散列比较均匀。

如果发生了哈希冲突,采用的是线性探测来解决,就是一个个往数组后面去寻找有没有没有被填充的空位来存储。这里有个特别的地方,就是清理过期的Entry的时候,会重新哈希过期的Entry对象后面的Entry对象,因为后面的这些Entry对象可能是有哈希冲突经过线性探测放在了后面,放在后面后查找次数也就多了,重新rehash计算索引,把Entry对象往前方,也就是尽可能减少线性探测的查找次数,提高访问速度。

为什么不用拉链法解决散列冲突呢?因为拉链法链表结点中的指针占用额外空间,如果把这些空间用来增大表容量,可以使得装载因子变小,从而减少开放寻址冲突,提高平均查找效率,效果也是类似的。同时ThreadLocal数据量也不会特别大,所以不需要红黑树来处理极端情况。


ThreadLocalMap是做什么的?

自定义的简单的哈希表的实现,散列方式是开放定址法之线性探测。

ThreadLocalMap数据结构

Entry数组存储数据

  1. Entry类是一个WeakReference
  2. Entry类存储了ThreadLocal这个key和对应的value
  3. 对ThreadLocal持有弱引用,对value持有强引用
1
2
3
4
5
6
7
8
9
10

static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

TheadLocal如何保证唯一性?

ThreadLocal作为映射表的Key,需要具备唯一的标识,每创建一个新的ThreadLocal,这个标识就变的跟之前不一样了。 如何保证每一个ThreadLocal的唯一性呢?

1
2
3
4
5
6
7
8
9
10
11
public class ThreadLocal<T> {
private static final int HASH_INCREMENT = 0x61c88647;

// 每一个ThreadLocal对象的HashCode都不一样
private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
// 下一个HashCode,是在已有基础上增加0x61c88647
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
}

ThreadLocal内部有一个名为threadLocalHashCode的变量,每创建一个新的ThreadLocal对象,这个变量的值就会增加0x61c88647。 正是因为有这么一个神奇的数字,它能够保证生成的Hash值可以均匀的分布在0~(2^N-1)之间,N是数组长度。 更多关于数字0x61c88647,可以参考Why 0x61c88647?

ThreadLocalMap如何计算value应该存在table数组中哪个索引位置?

Map的key是ThreadLocal

用ThreadLocal的属性threadLocalHashCode跟table数组长度做与运算得到索引位置

参考ThreadLocalMap的set()方法

ThreadLocalMap如何解决哈希冲突

开放定址法之线性探测法

求得索引后,table数组中当前索引有Entry,并且Entry的key不为null,则继续看数组的下一个位置是不是空的

参考ThreadLocalMap的set()方法

ThreadLocalMap的table数组空间不够放了怎么办?

一句话:

添加元素时如果已存储的元素个数超过装载因子就扩容为原容量的两倍

set()方法里会检查当前的存储的对象个数是否已经超出了阈值(threshold的值)大小,如果超出了,需要重新对table数组扩容为原来的2倍长度,并将所有的对象重新计算位置(rehash函数来实现),顺便清理掉Entry.get()为null的Entry,threshold为table数组长度的2/3,threshold相当于是map的装载因子。

ThreadLocalMap.expungeStaleEntry(int staleSlot)做了什么?

table数组中staleSlot位置的Entry的get()为null了,value不为null,在expungeStaleEntry()中把它的value置为null,size–

然后从staleSlot位置的下一个位置线性检查Entry,一直到Entry为null结束,如果

  1. Entry.get()为null,则把Entry的value置空,防止内存泄露
  2. Entry.get()不为null,重新计算当前Entry的索引,也就是rehash,放入rehash过后的索引位置,如果有冲突(已经存放了Entry)就线性向后查找空位进行存放。这样做是为了减少哈希冲突时线性查找次数。

ThreadLocalMap.cleanSomeSlots(int staleSlot)做了什么?

在新元素添加进来,或清理另一个过期的Entry时调用

对数级别时间复杂度清理对象,避免添加元素时线性扫描整个table数组,也防止垃圾堆积过多

TheadLocal存在有什么问题?为什么?如何解决?

问题:可能会内存泄露

为什么:因为ThreadLocal存储在ThreadLocalMap是弱引用,ThreadLocal被回收后,Entry还是被ThreadLocalMap的table数组引用,Entry的value是强引用,value可能会内存泄漏

解决:使用完ThreadLocal手动调用一下remove()

TheadLocal有哪些典型使用到的地方?

  1. 每个线程都有一个自己的Looper对象
  2. Android SQLiteDatabase中数据库连接每个线程都持有一个
  3. 用户session对话

ThreadLocal 适用于如下两种场景

  1. 每个线程需要有自己单独的实例
  2. 实例需要在多个方法中共享,但不希望被多线程共享

计算机结构

计算机可分为两种结构

  1. 冯诺依曼结构
  2. 哈佛结构

冯诺依曼结构提出计算机由运算器、控制器、存储器、输入设备、输出设备5个部分组成。

冯诺依曼结构特点是指令存储和数据存储合并在一起的存储结构,指令和数据统一编址,使用同一条总线传输,CPU读取指令和数据的操作是互斥的,同一时间只能做一件事,只能分时复用,无法并行,速度慢,CPU吞吐量低。

哈佛结构特点是指令存储和数据存储分开的存储结构,指令和数据独立独立编址,使用两条独立的总线传输,CPU读取指令和数据操作可以并行,速度快,CPU吞吐量高。

因为cpu速度快,而总线速度慢。冯诺依曼结构中,数据存储器和指令存储器使用同一总线,总线繁忙,cpu需要停下来等待总线读取数据。在哈佛结构中,数据存储器和指令存储器使用不同总线,减少了cpu停止工作等待数据读取的时间,因此提升了效率。

冯诺依曼结构由于指令和数据共用总线,速度慢、效率低、吞吐量低,但因为指令存储区域和数据存储区域大小可以动态调整,存储器利用率高,适用场景更通用,所以也便宜。

哈佛结构由于指令和数据分开存储,速度快、效率高、吞吐量大,但不能灵活调整存储区,适用比较做单一的功能,针对不同的功能要做单独的设计,成本也提高了。

冯诺依曼瓶颈

指令和数据存储在一起,导致指令读取和数据处理不能同时进行,而CPU运算速度远远超过主存储器读写的速度,CPU就会浪费时间等待主存数据读写,CPU吞吐量低。

解决瓶颈途径之一:

CPU访问主存数据具有时间局部性和空间局部性特点,在CPU和主存储器之间增加缓存,缓存的命中率很高,大大减少访问主存的次数。

同时CPU缓存采用哈佛结构,指令和数据分开存储,以提高存储量。

数据访问的时间局部性:如果一项信息最近被访问,那么近期很可能被再次访问。产生这个效果的典型原因是代码存在大量循环操作。

数据访问的空间局部性:将来马上要用到的信息,很可能与正在使用的信息,在空间存储位置上是临近的

所以当访问主存中某一个数据的时候,把这个数据相邻的数据都放入缓存中,可以大大减少CPU访问主存消耗的时间。

CPU缓存和内存为什么要分块?

CPU访问主存数据具有时间和空间局部性的特点。

时间局部性:如果一个数据被访问了,那么近期很可能被再次访问。产生这个效果的典型原因是代码存在大量循环操作。

空间局部性:访问内存上的一个数据,这个数据邻近位置的数据,最近也很大可能会被访问

所以应该把该数据邻近的位置的数据都一起读入缓存中,这样可以减少CPU访问主存的次数,CPU直接访问缓存的速度是很快的,这样CPU的吞吐量就提高了。

虚拟内存机制,也是建立了主存和外存之间的缓存关系,也是利用数据访问的局部性原理,把暂时不用的数据放到访问速度更慢的外存,需要频繁使用的数据按页交换到访问速度更快的主存中。这样内存中可以运行更多的进程。

缓存行

为了便于CPU缓存和主存交换数据,CPU缓存和主存都被划分位长度相等的块,缓存块又称为缓存行,大小为2的次方,CPU缓存从主存读入数据一次读一整块,向主存更新数据,也是一次写入一整块。

伪共享

多个CPU同时读写主存上某个连续区域的不同变量时,会各自把该该主存块拷贝一份放入自己的CPU缓存中,多个线程在访问缓存行内的不同变量时,由缓存一致性协议可知该缓存行会失效,多个CPU之间互相等待对方先把缓存行写入主存,自己再从主存读入最新的数据块,表现为缓存频繁未命中,CPU演变为直接与主存交互,CPU访问主存速度很慢,CPU吞吐量因此降低。

伪共享的解决:字节填充

保证不同的CPU处理的数据位于不同的缓存行中,就不会引发缓存行的频繁失效。

java里定义类的成员变量时,可以倾向于把不变的遍历放在一起(一组位置),易变的变量放在一起,使它们尽量不在同一个缓存行,这样每次对象的易变变量变化时,不会引起不变的属性所在缓存行失效,从而提高缓存命中率,避免频繁从主存读取数据,提高CPU吞吐量。

Jdk6中可以通过添加填充变量进行字节填充来解决伪共享,示例如下:

1
2
3
4
public class PaddingObject{
public volatile long value = 0L; // 实际数据
public long p1, p2, p3, p4, p5, p6; // 填充
}

想象PaddingObject里本来只有一个value属性,现在有多个线程需要读写一个PaddingObject数组(修改数组每一项里的value),由于数组的存储空间在内存中是连续的,根据CPU访问数据的空间局部性特点,CPU在访问数组某个元素的时候,会把该元素所在的内存块整个拷贝到CPU缓存中,这个内存块包含了数组中一些相邻的元素,多个线程同时修改数组中相邻的一些元素时,这个缓存行会频繁失效,CPU缓存命中率低,如果数组的每一个元素位于不同的内存块,也就可以位于不同的缓存行,多线程就不会竞争同一个缓存行

Jdk7中有的版本用上述方式无效,添加的无用变量会被优化去除,需要使用继承的方式组织优化,示例如下:

1
2
3
4
5
6
7
abstract class AbstractPaddingObject{
protected long p1, p2, p3, p4, p5, p6;// 填充
}

public class PaddingObject extends AbstractPaddingObject{
public volatile long value = 0L; // 实际数据
}

Jdk8开始提供了@sun.misc.Contended 注解进行字节填充,不用再手动声明无用的变量了,同时要开启 JVM 参数:-XX:-RestrictContended=false

字节填充会增大目标对象的体积,是用空间换时间。

伪共享应用场景

数组优于链表

从伪共享的角度,数据和链表的区别,不仅是结构上的区别,在缓存命中率上是完全不一样的,因为数组的存储空间连续,缓存命中率更高,链表的缓存命中率低,访问速度慢。

快速排序优于堆排序

堆排序访问数据时,也不是访问连续内存空间的数据,所以堆排序和快速排序虽然都有相近的时间复杂度,但是常用的还是快速排序

二维数组横向遍历优于竖向遍历

横向遍历二维数组,比竖向遍历缓存命中率要高,因为数组在内存中的存储是每行每行的连续存储,先按行遍历再按列遍历,会访问内存空间连续的数据,先按列遍历再按行遍历访问的不是内存存储空间连续的数据,数据不在同一块,缓存的命中率低

二维数组维数短的写在外层更优

二维数组,维数短的写在外层,缓存命中率更高,因为会减少跨缓存块访问数据,集中在一个缓存块上访问数据,如果长的一维写在外层,访问二维数组元素时,访问数据的内存地址跨度可能超过一个内存块长度,就要频繁的交换CPU缓存,缓存命中率低

Semaphore作用

如果限定某些资源最多有N个线程可以访问,那么超过N个则不允许再有线程来访问。
同时当现有线程结束后,就会释放,然后允许新的线程进来。

类似于锁的lock与 unlock过程 ,区别在于Semaphore多加锁和解锁是计数的。

示例:

1
2
3
Semaphore semaphore = new Semaphore(3);
semaphore.acquire(); // 获取一个许可
semaphore.release(); // 释放一个许可

Semephore构造函数传入0,也可以release进行+1

底层原理

Semaphore构造函数传递的值会保存在AQS的state变量中

调用acquire方法,尝试给state减1,如果state变为负数,就会阻塞当前线程,把当前线程作为结点插入到AQS队列中。如果state不为负数,则继续执行线程的后续代码。

调用release方法,会给state加1,如果AQS队列里有等待的线程,就唤醒等待的线程,并把结点移除队列。

3个线程交替打印123456789…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class SemaphoreDemo {

private static int n = 1;

public static void main(String[] args) {
Semaphore s1 = new Semaphore(1);
Semaphore s2 = new Semaphore(0);
Semaphore s3 = new Semaphore(0);
Thread t1 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
try {
s1.acquire();
System.out.println("T1: " + n);
n++;
s2.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t2 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
try {
s2.acquire();
System.out.println("T2: " + n);
n++;
s3.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t3 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
try {
s3.acquire();
System.out.println("T3: " + n);
n++;
s1.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
t1.start();
t2.start();
t3.start();
}
}

作用

  • CyclicBarrier构造函数传入一个数字N。
  • 线程调用CyclicBarrier.await()会阻塞等待,直到有第N个线程调用CyclicBarrier.await(),所有线程一起执行await()方法后续逻辑。
  • 再次调用await()可以继续这一波操作,循环使用。

比喻:

  • 人到齐了一起走,没到齐每个人都一直等着不走。
  • 走完了过后,下一波人来了继续这个流程。

可循环利用的屏障。

举例:

CyclicBarrier barrier = new CyclicBarrier(5);

然后各个线程调用barrier.await();

当有5个线程await()过后,会继续执行await()后续代码

底层原理

调用CyclicBarrier的await()方法,通过ReentrantLock先加锁,然后用Condition的await实现等待。

每次调用await()会计数,当第个N个线程执行await()后,会对Condition对象signalAll()来让所有等待线程的继续执行。

然后重新计数,继续同样的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}

/**
* Main barrier code, covering the various policies.
*/
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock;
lock.lock();
try {
final Generation g = generation;

if (g.broken)
throw new BrokenBarrierException();

if (Thread.interrupted()) {
breakBarrier();
throw new InterruptedException();
}

int index = --count;
if (index == 0) { // tripped
boolean ranAction = false;
try {
final Runnable command = barrierCommand;
if (command != null)
command.run();
ranAction = true;
nextGeneration();
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
}

// loop until tripped, broken, interrupted, or timed out
for (;;) {
try {
if (!timed)
trip.await();
else if (nanos > 0L)
nanos = trip.awaitNanos(nanos);
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) {
breakBarrier();
throw ie;
} else {
// We're about to finish waiting even if we had not
// been interrupted, so this interrupt is deemed to
// "belong" to subsequent execution.
Thread.currentThread().interrupt();
}
}

if (g.broken)
throw new BrokenBarrierException();

if (g != generation)
return index;

if (timed && nanos <= 0L) {
breakBarrier();
throw new TimeoutException();
}
}
} finally {
lock.unlock();
}
}

CountDownLatch和CyclicBarrier区别?

CountDownLatch主要是实现了1个或N个线程需要等待其他线程完成某项操作之后才能继续往下执行操作,描述的是1个线程或N个线程等待其他线程的关系。

CyclicBarrier主要是实现了多个线程之间相互等待,直到所有的线程都满足了条件之后各自才能继续执行后续的操作,描述的多个线程内部相互等待的关系。

CountDownLatch是一次性的,而CyclicBarrier则可以被重置而重复使用。

读写锁存在的意义?解决了什么问题?

synchronzied和ReentraintLock的锁是排他锁,同一时刻只允许一个线程访问同一个资源。

然而在读多写少的情况下,排他锁会让多个并发的读之间互斥,但多个线程同时读不会影响数据的一致性,写的时候保证获取的是排他锁保证数据一致性即可。

也就是读与读之间不互斥,写与读、写与写之间互斥。

这样的话就提高了系统整体的吞吐量。

ReentraintReadWriteLock实现原理是什么?

内部有ReadLock和WriteLock两个类,分别实现读锁和写锁。

两个Lock类里是通过Sync来实现锁的语义,Sync类继承AQS,并且分为非公平实现和公平的实现。

由于AQS的state是int类型的变量,在内存中占用4个字节,也就是32位。将其拆分为两部分:高16位和低16位,其中高16位用来表示读锁的个数,低16位用来表示写锁重入次数。

当设置读锁成功时,就将高16位加1,释放读锁时,将高16位减1;当设置写锁成功时,就将低16位加1,释放写锁时,将第16位减1。如下图所示。

这样读锁和写锁就可以都通过CAS去操作同一个state变量,实现读和写之间的互斥。

读写锁适用场景?

读多写少,又需要保持线程安全,可以有效提高吞吐量。

StampedLock解决了什么问题?

ReentraintReadWriteLock读写锁在读多写少的情况下,必须要等待所有读锁释放才能获取写锁,可能会导致写饥饿,写可能要等待很久,这是一种悲观的读锁。

正确的做法应该是,如果检测到有写入,已经获取读锁的线程不应该继续读了,把读锁让出来,给写入线程优先执行,写入完成了再读,这样就不会写饥饿了。

StampedLock做的就是,先不加锁的读数据,读完了检测一下是否有线程写过数据了,有写过数据则需要重新读最新的数据,并且要加悲观读锁读,以阻止新的写入锁获取,确保读的数据一定是最新修改的。

更通俗的讲就是在读锁没有释放的时候是可以获取到一个写锁,获取到写锁之后,读锁阻塞,这一点和读写锁一致,唯一的区别在于读写锁不支持在没有释放读锁的时候获取写锁。

StampedLock如何检测是否有数据写入?

在第一次读数据之前先从StampedLock获取一个stamp,如果有数据写入,StampedLock里的stamp就会变,然后第一次读数据后,检测stamp变了说明有写入。

StampedLock第二次加悲观读锁读数据不还是有同样的写饥饿问题吗?

确实面临同样的问题,但这种情况发生的概率很小,因为StampedLock使用场景就是读多写少,检测到有写操作后,可以认为大概率之后的一段时间是不会有写操作发生的。

StampedLock如何解决ABA问题?

StampedLock用一个long类型的state变量保存锁的状态,其中state的低7位存储读锁的个数,第8位存储写锁的标志,第8位为0表示没有获取到写锁,第8位为1表示已获取到写锁。

每次获取和释放写锁,都会给state加上1000 0000,也就是在state的第8位上加1,这样在高位就产生了进位,每次获取写锁后state的值都不一样,这样就可以知道发生了ABA。

long总共有64位,高位一共有64-8=56位,一共可以记录2的55次方种状态。

StampedLock为什么不设计为可重入?

StampedLock为了解决ABA问题,state变量低7位记录读锁个数,第8位记录是否获取了写锁,第9位到第64位记录了写锁的状态,没有地方存储写锁的重入次数。

对比ReentraintReadWriteLock的实现原理,其实也可以在state的高位中划分一部分区域记录写锁的重入次数。

StampedLock的锁升级是为了解决什么问题?

有时候写数据只在数据处于特定的条件下才去更改,也就是有可能不修改数据,那如果一开始先获取写锁就会阻碍读锁的获取,吞吐量降低,所以得先获取读锁,判断符合特定条件后,确定需要写入了,再获取写锁,此时就是锁的升级,把读锁释放升级为写锁,前提是当前只有一个读锁;

如果有多个读锁,升级就失败,转为获取正常的写锁,获取不到就阻塞当前线程。

StampedLock有什么缺点?

  1. 不可重入
  2. 不支持Condition
  3. 不支持公平竞争

所以也不能完全替代synchronized和ReentrantLock。

Object.wait()为什么要配合while使用?

wait之前一般都是要判断某个条件成立才会wait,这个判断条件需要写成while:

1
2
3
while (check pass) {
wait();
}

如果写成if,wait过后当前线程交出锁,等当前线程重新被唤起后,条件是否满足是不知道的,其他线程可能修改了条件的状态,要重新判断一下,否则可能条件未满足就继续执行了。

以生产者消费者模型为例。

  • 有1个生产者往缓冲区加数据,有2个消费者从缓冲区取数据。
  • 消费者取数据前会检查缓冲区是否为空,不为空才能取数据,为空的话要等待。
  • 假设两个消费者线程都等待了。
  • 然后生产者往缓冲区添加数据后做notifyAll唤醒所有消费者,会唤醒两个等待的消费者线程,让消费者线程从监视器的等待队列移动到锁竞争队列,两者竞争锁。
  • 其中一个线程竞争到锁后,消费了缓冲区数据,缓冲区没有数据了,释放锁后另外一个消费者获得到了锁,开始取从缓冲区取数据,而没有再做条件检查了。

这种现象叫做虚假唤醒。

Object的notify()和notifyAll()有什么区别?

每一个对象都有一个内部锁,即监视器(Monitor),虚拟机会给每个对象维护两个线程集合(可能是队列),一个叫Entry Set(入口集),另外一个叫Wait Set(等待集),对于任意对象object,object的Entry Set用于存储等待获取object内部锁的所有线程,object的Wait Set存储执行了object.wait()和object.wait(long timeout)的线程。

  • notify()会唤醒Wait Set里的一个线程。
  • notifyAll()会唤醒Wait Set里的所有线程,线程被唤醒后去竞争获取锁,没有获取锁的线程进入Entry Set。

什么时候用notify()?什么时候用notifyAll()?

如果所有线程都在等待相同的条件,并且一次只有一个线程可以从条件变为true,则可以使用notify。

在这种情况下,notify是优于notifyAll 因为唤醒所有这些因为我们知道只有一个线程会受益而所有其他线程将再次等待,所以调用notifyAll方法只是浪费CPU。

虽然这看起来很合理,但仍有一个警告,即无意中的接收者吞下了关键通知。通过使用notifyAll,我们确保所有收件人都会收到通知。

Object的wait()/notifyAll()和Condition的await()/signalAll()的区别?

以生产者消费者模型为例。

  • 使用wait()、notify()/notifyAll()的缺点在于在生产者唤醒消费者、或者消费者唤醒生产者时,由于生产者和消费者使用同一个锁,所以生产者也会将生产者唤醒,消费者也会将消费者唤醒。
  • 如果能让消费者只唤醒生产者,或者生产者只唤醒消费者,就没有性能浪费了。
  • 所以一个ReentrantLock支持创建多个Condition,以应对这种场景。
  • synchronized的锁对象没有提供多条件唤醒。

ArrayBlockingQueue是典型的生产者消费者的例子,源码是很好的参考。

synchronized作用

让一段代码在多线程之间互斥访问,保证原子性、有序性、可见性,并且是可重入的。

synchronized使用方式

  • synchronized修饰代码块时需要传入一个对象作为锁,这个对象可以是任意的,进入同步代码块前要获取这个锁,没有获得锁的线程就要阻塞等待,同步代码块执行完释放锁。
  • 修饰成员方法时,锁是对象实例。
  • 修饰静态方法时,锁是类的class实例。

synchronized原理综述

每个Java对象都关联一个监视器锁对象,同步代码块在字节码层面是通过在代码块的指令前后加上monitorenter和monitorexit指令来标识的。

当线程执行到monitorenter指令时,当前线程试图获取监视器对象所有权,如果未加锁或者已被当前线程持有,就把锁计数加1;执行到monitorexit指令的时候,锁计数减1。锁计数为0时,锁就被释放了。

如果当前线程获取监视器对象失败,线程会阻塞等待,线程会作为一个结点存入监视器对象的锁池队列中,等待唤醒。

监视器对象还有一个等待池队列,在同步代码块中调用锁对象的wait方法的时候,线程会阻塞,并被封装为一个结点,进入监视器对象的等待池队列,调用锁对象的notify和notifyAll的时候,会把等待池队列中的线程放入锁池队列中,只有锁池中的线程才能参与竞争锁。

wait和notify用来线程之间同步过程的,比如实现生产者消费者模型。

监视器锁底层是通过操作系统的互斥锁实现的,操作系统做线程切换需要从用户态转换到内核态,所以比较消耗性能。这种依赖于操作系统互斥锁而实现的锁叫做重量级锁。

如果线程竞争并不激烈,切换线程的开销是不划算的,特别是多个线程在不同的时段获取同一把锁,可以用自旋锁,就是CAS+循环,CAS由硬件之间实现,开销比切换线程开销要小,这就是轻量级锁,在JDK6引入。

锁信息具体存储在对象头的MarkWord里,一个对象在虚拟机的内存布局分为三部分,对象头、实例数据、对齐填充,其中对象头又分为MarkWord、类型指针、数组长度,MarkWord存储了锁相关的信息、hashCode、垃圾回收的分代年龄等。

轻量级锁的CAS操作具体修改的是锁对象头的mark word,修改为指向当前线程栈帧中的锁记录的指针。虚拟机会为每个线程在当前线程栈帧中创建一块锁记录空间,存储锁对象的对象头中的mark word的拷贝。

如果CAS修改成功,当前线程就获得了轻量级锁。

如果更新失败,说明有线程竞争,开始自旋,自旋超过一定次数,升级为重量级锁。避免竞争激烈的情况下自旋空耗CPU。

如果压根都没有线程竞争,只有单线程访问同步代码块,其实每次进行CAS操作获取轻量级锁也是没有必要的开销,所以JDK6又引入了偏向锁,只在第一次申请锁的时候,对Java对象头的MARK WORD做CAS操作,记录下当前申请锁的线程ID,退出同步代码块后,也不释放锁,锁对象的对象头中记录的还是当前的线程ID,同一个线程再次进入这个锁的同步代码块的时候,检查锁的对象头中记录的线程ID是否还是自己,是的话就直接进入,不用再CAS了。当有多个线程要申请锁的时候,就会升级到轻量级锁。

轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

ReentrantLock与synchronized的区别?

ReentrantLock与synchronized有相同的功能和语义,添加了更多灵活的功能。
synchronized能干的ReentrantLock都能干,ReentrantLock能干synchronized不一定能干。

最主要区别有三点:支持等待可中断、可实现公平锁、锁可以绑定多个条件。

详细区别:
| |ReentrantLock|synchronized
|—|—|—|
|底层实现|继承AQS|监视器模式
|灵活性|支持响应中断、超时、尝试获取锁|不灵活
|释放锁形式|必须显式调用unlock()释放锁,可跨方法调用|自动释放监视器,不可跨方法释放
|锁类型|公平锁和非公平锁|非公平锁
|条件队列|可关联多个条件队列|只有一个条件队列
|错误排查|没有释放锁,很追溯发生错误的位置,因为没有记录应该释放锁的时间和位置难度|synchronized加锁解锁过程有完整的日志

该用ReentrantLock还是synchronized?

除非需要ReentrantLock的特定的功能,否则还是应该优先使用synchronized,因为简单易用,不会忘记释放锁。

ReentrantLock实现原理

底层通过继承AbstractQueuedSynchronizer实现。

大致过程:

  • 先通过cas修改int类型的state变量,修改成功则获取互斥锁成功。
  • cas修改失败则把当前线程生成一个节点,放入一个等待队列中,线程被挂起。
    • 等待队列操作也都是通过cas加自旋的方式来完成,避免同步开销。
  • 释放锁后,会唤醒队列去自旋CAS去修改state来获取互斥锁。

state变量记录锁的重入次数。

synchronized锁升级

  • jdk1.6之前synchronized直接就上重量级锁。
  • jdk1.6开始synchronized会先由无锁转为偏向锁,再转为轻量级锁,再转为重量级锁,锁只能升级不能降级。

偏向锁、轻量级锁是针对synchronized优化。

对象头

JVM中,对象在内存中除了存储对象本身的数据,还会额外存储关于对象的一些附加信息。

  • 普通对象的对象头中存储mark word和类型指针(指向对象所属类的指针)。
  • 数组对象还会存储数组长度。

mark word存储对象的hashcode、GC分代年龄、锁状态等信息。

mark word长度在32位系统上为32位,64位系统上长度为64位。

为了在有限的空间存储较多的信息,其数据格式不固定,数据位共享复用。

当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID;

当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;

当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。

偏向锁

研究发现,大多数情况下不仅不存在锁竞争,而且总是由同一个线程多次重入,为了让同一个线程多次重入获取锁的代价更低,就引入了偏向锁的概念。

偏向锁获取过程:

  1. 当前线程访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认是否为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致当前获得偏向锁的线程被暂停)
  5. 执行同步代码。

偏向锁的释放:

偏向锁只有当遇到其他线程也尝试获取偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。(也就是对于获取偏向锁的线程 只有lock的动作,没有unlock的动作,这是因偏向的需要,即使可能这个线程已经死亡。)偏向锁的撤销步骤如下:

  1. 等到全局安全点(在这个时间点上没有正在执行的字节码)。
  2. 暂停持有偏向锁的线程,然后检查持有偏向锁的线程是否还活着,如果线程不处于活动状态,则将对象头设置为无锁状态。争抢锁的线程会再走生成偏向锁的过程,然后成为偏向锁的拥有者。
  3. 如果持有偏向锁的线程还处于活动状态,则将锁升级为轻量级锁。

轻量级锁

轻量级锁实现是自旋锁,没有抢到锁的线程将自旋,即不停的循环判断是否能获取到锁,不会让线程阻塞。获取锁的操作就是通过CAS修改对象头的锁标记位。

长时间的循环是很占用CPU的,一个线程持有锁,其他线程只能原地空耗CPU,不执行任何有效的任务,这种现象称为busy-waiting。

利用短时间的busy-waiting换取线程的阻塞和唤醒在用户态和内核态切换的开销。

busy-waiting是有限度的(JVM可以设置参数调节循环次数上限),如果锁竞争激烈,自旋超过一定次数,就升级为重量级锁。

具体的CAS过程:

  • 当线程请求锁时,若该锁对象的Mark Word中标志位为01(未锁定状态),则在该线程的栈帧中创建一块名为『锁记录』的空间,然后将锁对象的Mark Word拷贝至该空间;最后通过CAS操作将锁对象的Mark Word指向该锁记录;
  • 若CAS操作成功,则轻量级锁的上锁过程成功;
  • 若CAS操作失败,再判断当前线程是否已经持有了该轻量级锁;若已经持有,则直接进入同步块;若尚未持有,则表示该锁已经被其他线程占用,此时轻量级锁就要膨胀成重量级锁。

轻量级锁比重量级锁性能更高的前提是:

  • 在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量以外,还额外发生了CAS操作,因此更慢!

轻量级锁是为了在线程交替执行同步块时提高性能。

重量级锁

阻塞所有竞争锁但未获得锁的线程。

实现依赖于操作系统的同步函数,在linux上使用mutex互斥锁,涉及到用户态和内核态的切换、进程线程上下文的切换,成本较高。

悲观锁

悲观锁更新的方式认为:在更新数据的时候大概率会有其他线程去争夺共享资源

所以悲观锁的做法是:第一个获取资源的线程会将资源锁定起来,其他没争夺到资源的线程只能进入阻塞队列,等第一个获取资源的线程释放锁之后,这些线程才能有机会重新争夺资源。

乐观锁

乐观锁更新方式认为:在更新数据的时候其他线程争抢这个共享变量的概率非常小,所以更新数据的时候不会对共享数据加锁。

但是在正式更新数据之前会检查数据是否被其他线程改变过,如果未被其他线程改变过就将共享变量更新成最新值,如果发现共享变量已经被其他线程更新过了,就重试,直到成功为止。

CAS机制就是乐观锁的典型实现。

CAS

CAS,是Compare and Swap的简称,在这个机制中有三个核心的参数:

  • 主内存中存放的共享变量的值:V(一般情况下这个V是内存的地址值,通过这个地址可以获得内存中的值)
  • 工作内存中共享变量的副本值,也叫预期值:A
  • 需要将共享变量更新到的最新值:B

CAS优点

  1. 可以保证变量操作的原子性;
  2. 并发量不是很高的情况下,使用CAS机制比使用锁机制效率更高;
  3. 在线程对共享资源占用时间较短的情况下,使用CAS机制效率也会较高。

CAS缺点

ABA问题

ABA问题:CAS在操作的时候会检查变量的值是否被更改过,如果没有则更新值,但是带来一个问题,最开始的值是A,接着变成B,最后又变成了A。经过检查这个值确实没有修改过,因为最后的值还是A,但是实际上这个值确实已经被修改过了。为了解决这个问题,在每次进行操作的时候加上一个版本号,每次操作的就是两个值,一个版本号和某个值,A——>B——>A问题就变成了1A——>2B——>3A。在jdk中提供了AtomicStampedReference类解决ABA问题,用Pair这个内部类实现,包含两个属性,分别代表版本号和引用,在compareAndSet中先对当前引用进行检查,再对版本号标志进行检查,只有全部相等才更新值。

可能会消耗较高的CPU

看起来CAS比锁的效率高,从阻塞机制变成了非阻塞机制,减少了线程之间等待的时间。每个方法不能绝对的比另一个好,在线程之间竞争程度大的时候,如果使用CAS,每次都有很多的线程在竞争,也就是说CAS机制不能更新成功。这种情况下CAS机制会一直重试,这样就会比较耗费CPU。因此可以看出,如果线程之间竞争程度小,使用CAS是一个很好的选择;但是如果竞争很大,使用锁可能是个更好的选择。在并发量非常高的环境中,如果仍然想通过原子类来更新的话,可以使用AtomicLong的替代类:LongAdder。

不能保证代码块的原子性

Java中的CAS机制只能保证共享变量操作的原子性,而不能保证代码块的原子性。

View Animation原理

  1. 首先,当调用了 View.startAnimation() 时动画并没有马上就执行,而是通过 invalidate() 层层通知到 ViewRootImpl 发起一次遍历 View 树的请求,而这次请求会等到接收到最近一帧到了的信号时才去发起遍历 View 树绘制操作。
  2. 从 DecorView 开始遍历,绘制流程在遍历时会调用到 View 的 draw() 方法,当该方法被调用时,如果 View 有绑定动画,那么会去调用applyLegacyAnimation(),这个方法是专门用来处理动画相关逻辑的。
  3. 在 applyLegacyAnimation() 这个方法里,如果动画还没有执行过初始化,先调用动画的初始化方法 initialized(),同时调用 onAnimationStart() 通知动画开始了,然后调用 getTransformation() 来根据当前时间计算动画进度,紧接着调用 applyTransformation() 并传入动画进度来应用动画。
  4. getTransformation() 这个方法有返回值,如果动画还没结束会返回 true,动画已经结束或者被取消了返回 false。所以 applyLegacyAnimation() 会根据 getTransformation() 的返回值来决定是否通知 ViewRootImpl 再发起一次遍历请求,返回值是 true 表示动画没结束,那么就去通知 ViewRootImpl 再次发起一次遍历请求。然后当下一帧到来时,再从 DecorView 开始遍历 View 树绘制,重复上面的步骤,这样直到动画结束。
  5. 有一点需要注意,动画是在每一帧的绘制流程里被执行,所以动画并不是单独执行的,也就是说,如果这一帧里有一些 View 需要重绘,那么这些工作同样是在这一帧里的这次遍历 View 树的过程中完成的。每一帧只会发起一次 perfromTraversals() 操作。

View Animation 是在绘制的时候,改变 view 的视觉效果来实现动画的。所以不会对 view 的测量和布局过程有影响。

View 的动画是通过触发绘制过程来执行 draw 的。因为动画是连续的,所以需要不停的触发。

View Animation为什么移动位置后,点击事件的响应依旧是在原来位置上?

因为动画是在 draw 时候形成的,也就是说只是视觉效果。其并没有改变它本身在父类中的位置;

View Animation中如果对 View 做放大缩小得动画,那么其宽度高度值是否会变化?

View Animation 是在绘制的时候,改变 view 的视觉效果来实现动画的。所以不会对 view 的测量和布局过程有影响。

属性动画的原理

  1. ValueAnimator 属性动画调用了 start() 之后,会先去进行一些初始化工作,包括变量的初始化、通知动画开始事件;
  2. 然后通过 AnimationHandler 将其自身 this 添加到 mAnimationCallbacks 队列里,AnimationHandller 是一个单例类,为所有的属性动画服务,列表里存放着所有正在进行或准备开始的属性动画;
  3. 如果当前存在要运行的动画,那么 AnimationHandler 会去通过 Choreographer 向底层注册监听下一个屏幕刷新信号,当接收到信号时,它的 mFrameCallback 会开始进行工作,工作的内容包括遍历列表来分别处理每个属性动画在当前帧的行为,处理完列表中的所有动画后,如果列表还不为 0,那么它又会通过 Choreographer 再去向底层注册监听下一个屏幕刷新信号事件,如此反复,直至所有的动画都结束。
  4. AnimationHandler 遍历列表处理动画是在 doAnimationFrame() 中进行,而具体每个动画的处理逻辑则是在各自,也就是 ValueAnimator 的 doAnimationFrame() 中进行,各个动画如果处理完自身的工作后发现动画已经结束了,那么会将其在列表中的引用赋值为空,AnimationHandler 最后会去将列表中所有为 null 的都移除掉,来清理资源。
  5. 每个动画 ValueAnimator 在处理自身的动画行为时,首先,如果当前是动画的第一帧,那么会根据是否有”跳过片头”(setCurrentPlayTime())来记录当前动画第一帧的时间 mStartTime 应该是什么。
  6. 第一帧的动画其实也就是记录 mStartTime 的时间以及一些变量的初始化而已,动画进度仍然是 0,所以下一帧才是动画开始的关键,但由于属性动画的处理工作是在绘制界面之前的,那么有可能因为绘制耗时,而导致 mStartTime 记录的第一帧时间与第二帧之间隔得太久,造成丢了开头的多帧,所以如果是这种情况下,会进行 mStartTime 的修正。
  7. 修正的具体做法则是当绘制工作完成后,此时,再根据当前时间与 mStartTime 记录的时间做比较,然后进行修正。
  8. 如果是在动画过程中的某一帧才出现绘制耗时现象,那么,只能表示无能为力了,丢帧是避免不了的了,想要解决就得自己去分析下为什么绘制会耗时;而如果是在第一帧是出现绘制耗时,那么,系统还是可以帮忙补救一下,修正下 mStartTime 来达到避免丢帧。

  1. 当接收到屏幕刷新信号后,AnimationHandler 会去遍历列表,将所有待执行的属性动画都取出来去计算当前帧的动画行为。
  2. 每个动画在处理当前帧的动画逻辑时,首先会先根据当前时间和动画第一帧时间以及动画的持续时长来初步计算出当前帧时动画所处的进度,然后会将这个进度值等价转换到 0-1 区间之内。
  3. 接着,插值器会将这个经过初步计算之后的进度值根据设定的规则计算出实际的动画进度值,取值也是在 0-1 区间内。
  4. 计算出当前帧动画的实际进度之后,会将这个进度值交给关键帧机制,来换算出我们需要的值,比如 ValueAnimator.ofInt(0, 100) 表示我们需要的值变化范围是从 0-100,那么插值器计算出的进度值是 0-1 之间的,接下去就需要借助关键帧机制来映射到 0-100 之间。
  5. 关键帧的数量是由 ValueAnimator.ofInt(0, 1, 2, 3) 参数的数量来决定的,比如这个就有四个关键帧,第一帧和最后一帧是必须的,所以最少会有两个关键帧,如果参数只有一个,那么第一帧默认为 0,最后一帧就是参数的值。当调用了这个 ofInt() 方法时,关键帧组也就被创建了。
  6. 当只有两个关键帧时,映射的规则是,如果没有设置估值器,那么就等比例映射,比如动画进度为 0.5,需要的值变化区间是 0-100,那么等比例映射后的值就是 50,那么我们在 onAnimationUpdate 的回调中通过 animation.getAnimatedValue() 获取到的值 50 就是这么来的。
  7. 如果有设置估值器,那么就按估值器的规则来进行映射。
  8. 当关键帧超过两个时,需要先找到当前动画进度是落于哪两个关键帧之间,然后将这个进度值先映射到这两个关键帧之间的取值,接着就可以将这两个关键帧看成是第一帧和最后一帧,那么就可以按照只有两个关键帧的情况下的映射规则来进行计算了。
  9. 而进度值映射到两个关键帧之间的取值,这就需要知道每个关键帧在整个关键帧组中的位置信息,或者说权重。而这个位置信息是在创建每个关键帧时就传进来的。onInt() 的规则是所有关键帧按等比例来分配权重,比如有三个关键帧,第一帧是 0,那么第二帧就是 0.5, 最后一帧 1。