原子类

基本类型原子类

1
2
3
4
5
6
class MyNumber {
AtomicInteger counter = new AtomicInteger();
public void addPlus() {
counter.getAndIncrement();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
MyNumber myNumber = new MyNumber();
CountDownLatch countDownLatch = new CountDownLatch(SIZE);

for (int i = 1; i <= 50; i++) {
new Thread(() -> {
try {
for (int j = 1; j <= 1000; j++) {
myNumber.addPlus();
}
} finally {
countDownLatch.countDown();
}
}, String.valueOf(i)).start();
}

try {
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

System.out.println(Thread.currentThread().getName() + "\t" + myNumber.counter.get());

数组类型原子类

1
2
3
4
5
6
7
8
AtomicIntegerArray array = new AtomicIntegerArray(5);

for (int i = 0; i < array.length(); i++) {
System.out.println(array.get(i));
}

int i = array.getAndSet(0, 2022);
System.out.println(i + "\t" + array.get(0));

引用类型原子类

  • AtomicReference => 自旋锁
  • AtomicStampedReference携带版本号的引用类型原子类,可以解决ABA问题,解决修改过几次状态戳原子引用
  • AtomicMarkableReference原子更新带有标记位的引用类型对象,解决是否修改过,它的定义就是将状态戳简化为true/false,类似一次性筷子
1
static AtomicMarkableReference markableReference = new AtomicMarkableReference(100, false);
1
2
3
4
5
6
7
8
9
10
new Thread(() -> {
boolean marked = markableReference.isMarked();
System.out.println(Thread.currentThread().getName() + "\t" + marked);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
markableReference.compareAndSet(100, 1000, marked, !marked);
}, "t1").start();
1
2
3
4
5
6
7
8
9
10
11
12
13
new Thread(() -> {
boolean marked = markableReference.isMarked();
System.out.println(Thread.currentThread().getName() + "\t" + marked);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
boolean b = markableReference.compareAndSet(100, 2000, marked, !marked);
System.out.println(Thread.currentThread().getName() + "\t" + b);
System.out.println(Thread.currentThread().getName() + "\t" + markableReference.isMarked());
System.out.println(Thread.currentThread().getName() + "\t" + markableReference.getReference());
}, "t2").start();

t1 false t2 false t2 false t2 true t2 1000

对象的属性原子类

  • AtomicIntegerFieldUpdater原子更新对象中int类型字段的值
  • AtomicLongFieldUpdater原子更新对象中Long类型字段的值
  • AtomicReferenceFieldUpdater原子更新引用类型字段的值
  • 使用目的:以一种线程安全的方式操作非线程安全对象内的某些字段
  • 使用要求:更新对象属性必须使用public volatile修饰符。因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法new Updater()创建一个更新器,并且需要设置想要更新的类和属性

面试题: 你在哪里用了volatile?AtomicReferenceFieldUpdater

1
2
AtomicIntegerFieldUpdater<BankAccount> fieldUpdater =
AtomicIntegerFieldUpdater.newUpdater(BankAccount.class, "money");
1
2
AtomicReferenceFieldUpdater<MyVar, Boolean> refFieldUpdater =
AtomicReferenceFieldUpdater.newUpdater(MyVar.class, Boolean.class, "isInit");

LongAdder和LongAccumulator

面试题[参考]: volatile解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。说明:如果是count++操作,使用如下类实现: AtomicInteger count = new AtomicInteger(); count.addAndGet(1);如果是JDK8,推荐使用LongAdder对象,比AtomicLong性能更好(减少乐观锁的重试次数)

  • LongAdder只能用来计算加法,且从零开始计算
  • LongAccumulator提供了自定义的函数操作

LongAdder的基本思路就是分散热点,将value值分散到一个Cell数组中,不同线程命中到数组的不同槽中,各个线程只对自己槽中的哪个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽中的变量值累加返回。sun()会将所有Cell数组中的value和base累加作为返回值,核心的思想就是将之前AtomicLong一个value的更新压力分散到多个value中去,从而降级更新热点

  • 内部有一个base变量,一个Cell数组
  • base变量:低并发,直接累加到该变量上
  • Cell[]数组:高并发,累加进各个线程自己的槽Cell[i]中

LongAdder在无竞争的情况,跟AtomicLong一样,对同一个base进行操作,当出现竞争关系时则是采用化整为零分散热点的做法,用空间换时间,用一个数组cells,将一个value拆分进这个数组cells。多个线程需要同时对value进行操作时候,可以对线程id进行hash得到hash值,再根据hash值映射到这个数组cells的某个下标,再对该下标所对应的值进行自增操作,将数组线程操作完毕,将数组cells的所有值和base都加起来作为最终结果

  • 最初无竞争时只更新base
  • 如果更新base失败后,首次新建一个Cell[]数组
  • 当多个线程竞争同一个Cell比较激烈时,可能就要对Cell扩容

longAdder add方法源码

  • Cell[] as; long b, v; int m; Cell a;
  • as是striped64中的cells数组属性
  • b是Striped64中的base属性
  • v是当前线程hash到的Cell中存储的值
  • m是cells的长度减1,hash时作为掩码使用
  • a是当前线程hash到的Cell
  • if ((as = cells) != null || !casBase(b = base, b + x))
  • 首次首线程((as = cells) = null)一定是false,此时casBase方法,以CAS的方式更新base值,且只有当cas失败是呢,才会走到if中
  • 条件1: cells不为空
  • 条件2: cas操作base失败,说明其他线程先一步修改了base正在出现竞争
  • boolean uncontended true表示无竞争,false表示竞争激烈,多个线程hash到同一个Cell,可能要扩容
  • if (as == null || (m = as.length - 1) < 0 || (a = as[getProbe() & m]) == null || !(uncontended = a.cas(v = a.value, v + x)))
  • 条件1: Cells为空
  • 条件2: 应该不会出现
  • 条件3: 当前线程所在的Cell为空,说明当前线程还没有更新过Cell,应该初始化一个Cell
  • 条件4: 更新当前线程所在的Cell失败,说明现在竞争激烈,多个线程hash到了同一个Cell,应扩容
  • getProbe()方法返回的是线程中的threadLocalRandomProbe字段
  • 它是通过随机数生成的一个值,对于一个确定的线程这个值是固定的(除非刻意修改它)

longAccumulate方法的入参

long x 需要增加的值,一般默认都是1
LongBinaryOperator fn默认传递的是null
wasUncontended竞争标识,如果是false则代表有竞争。只有cells初始化之后,并且当前线程CAS竞争修改失败,才会是false

Striped64中一些变量或者方法的定义
  1. base: 类似于AtomicLong中全局的value的值,在没有竞争情况下数据直接累加到base上,或者cells扩容时,也需要将数据写入到base上
  2. collide: 表示扩容意向,false,一定不会扩容,true可能会扩容
  3. cellsBusy: 初始化cells或者扩容cells需要获取锁,0:表示无锁状态 1: 表示其他线程已经持有了锁
  4. casCellsBusy(): 通过CAS操作修改cellsBusy的值,CAS成功代表获取锁,返回true
  5. NCPU: 当前计算机CPU数量,Cell数组扩容时会用到
  6. getProbe(): 获取当前线程的hash值
  7. advanceProbe(): 重置当前线程的hash值
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
for (;;) {
Cell[] as; Cell a; int n; long v;
if ((as = cells) != null && (n = as.length) > 0) {
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
Cell r = new Cell(x); // Optimistically create
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try { // Recheck under lock
Cell[] rs; int m, j;
if ((rs = cells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
else if (!collide)
collide = true;
else if (cellsBusy == 0 && casCellsBusy()) {
try {
if (cells == as) { // Expand table unless stale
Cell[] rs = new Cell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
h = advanceProbe(h);
}
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
boolean init = false;
try { // Initialize table
if (cells == as) {
Cell[] rs = new Cell[2];
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break; // Fall back on using base
}
  • 判断当前线程hash后指向的数据位置元素是否为空,如果为空则将Cell数据放入数组中,跳出循环,如果不空则继续循环
  • wasUncontended表示cells初始化后,当前线程竞争修改失败,wasUncontended = false,这里只是重新设置了这个值为true,紧接着执行advanceProbe(h)重置当前线程的hash,重新循环
  • 说明当前线程对应数组中有了数据,也重置过hash值,这时通过CAS操作尝试对当前数组中的value值进行累加x操作,x默认为1,如果CAS成功则直接跳出循环
  • 如果n大于CPU最大容量,不可扩容,并通过下面的h = advanceProbe(h)方法修改线程的probe再重新尝试
  • 如果扩容意向collide是false则修改它为true,然后重新计算当前线程的hash值继续循环,乳沟当前数组长度已经大于CPU的核数,就会再次设置扩容意向collide = false(见上一步)
  • 按位左移以为来操作,扩容大小为之前容量的两倍,扩容后再将之前数组的元素拷贝到新数组中,释放锁设置cellsBusy = 0,设置扩容状态,然后继续执行循环

LongAdder sum方法源码

sum()会将所有Cell数组中的value和base累加作为返回值。核心的思想就是将之前AtomicLong一个value的更新压力分散到多个value中去,从而降级更新热点

ThreadLocal

ThreadLocal提供线程局部变量。这些变量与正常的变量不同,因为每一个线程在访问ThreadLocal实例的时候(通过get或者set方法)都有自己独立初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态(例如用户ID与事务ID)与线程关联。ThreadLocal实现每一个线程都有自己专属的本地变量副本(自己用自己的变量不麻烦别人,不和其他人共享,人人有份,人各一份),主要解决了让每一个线程绑定自己的值,通过使用get和set方法,获取默认值或将其值更改为当前线程所存副本的值从而避免了线程安全问题,比如我们之前讲解的8锁案例,资源类是使用同一部手机,多个线程抢夺同一个手机使用,假如人手一份是不是天下太平?

方法详细信息

  • initialValue: 返回此线程局部变量的当前线程的初始值。 该方法将被调用的第一次一个线程访问与可变get()方法,除非线程先前调用的set(T)方法,在这种情况下initialValue方法将不被调用的线程。 通常,每个线程最多调用一次此方法,但如果后续调用remove()后跟get() ,则可以再次调用此方法。这个实现只返回null ; 如果程序员希望线程局部变量具有除null之外的初始值, ThreadLocal必须对ThreadLocal进行子类化,并且重写此方法。 通常,将使用匿名内部类
  • withInitial: 创建一个线程局部变量。 通过调用get上的Supplier方法确定变量的初始值。S:线程本地值的类型,supplier:用于确定初始值的供应商
  • get: 返回当前线程的此线程局部变量副本中的值。 如果变量没有当前线程的值,则首先将其初始化为调用initialValue()方法返回的值
  • set: 将此线程局部变量的当前线程副本设置为指定值。 大多数子类都不需要重写此方法,仅依靠initialValue()方法来设置线程局部的值

因为每个Thread内有自己的实例副本,且该副本只由当前线程自己使用,既然其它Thread不可访问,那就不存在多线程间共享的问题。统一设置初始值,但是每个线程对这个值的修改都是各自线程独立的

源码分析

ThreadLocalMap从字面上就可以看出这是一个保存ThreadLocal对象的map(其实是ThreadLocal为key),不过是经过了两层包装的ThreadLocal对象: JVM内部维护了一个线程版的Map(通过ThreadLocal对象的set方法,结果把ThreadLocal对象自己当做key,放进了ThreadLocalMap中),每个线程要用到这个T的时候,用当前的线程去Map里面获取,通过这样让每个线程都拥有了自己独立的变量,人手一份,竞争条件被彻底消除,在并发模式下是绝对安全的变量

内存泄漏

ThreadLocalMap从字面上就可以看出这时一个保存ThreadLocal对象的map(以ThreadLocal为Key),不过是经过了两层包装的ThreadLocal对象:

1
2
3
4
5
6
7
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
  • 第一层包装是使用WeakReference> 将ThreadLocal对象变成一个弱引用对象
  • 第二层包装是定义了一个专门的类Entry来扩展WeakReference>

强引用

当内存不足时,JVM开始垃圾回收,对于强引用的对象,就算是出现了OOM也不会对该对象进行回收,死都不收。强引用是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还活着,垃圾收集器不会碰这种对象。在Java中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个独享被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到,JVM也不会回收。因此强引用是造成Java内存泄漏的主要原因之一。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应引用赋值为Null,一般就是可以被垃圾收集的了(当然具体回收时机还是要看垃圾收集策略)

软引用

软引用是一种相对强引用弱化了一些的引用,需要java.lang.ref.SoftReference类来实现,可以让对象豁免一些垃圾收集。对于只有软引用的对象来说,当内存充足时它不会被回收,当内存不足时它会被回收。软引用通常用在对内存敏感的程序中,比如告诉缓存就有用到软引用,内存够用的时候就保留,不够用就回收

弱引用

弱引用需要用java.lang.ref.WeakReference类来实现,它比软引用生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM的内存空间是否足够,都会回收该对象占用的内存

软引用和弱引用的使用场景

假如有一个应用需要读取大量的本地图片

  • 如果每次读取图片都从硬盘读取则会严重影响性能
  • 如果一次性全部加在到内存中又可能造成内存溢出

此时使用软引用可以解决这个问题。用一个HashMap来保存图片和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题

1
Mao<String, SoftReference<Bitmap>> imageCache = new HashMap<String, SoftReference<Bitmap>>();

虚引用

  1. 虚引用必须和引用队列(ReferenceQueue)联合使用。虚引用需要java.lang.PhantomReference类来实现,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(ReferenceQueue)联合使用
  2. PhantomReference的get方法总是返回null。虚引用的主要作用是跟踪对象被垃圾回收的状态。仅仅是提供了一种确保对象被finalize以后,做某些事情的通知机制。PhantomReference的get方法总是返回null,因此无法访问对应的引用对象
  3. 处理监控通知使用。换句话说,设置虚引用关联对象的唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步地处理,用来实现比finalize机制更灵活地回收操作

关系

ThreadLocal是一个壳子,真正的存储结构是ThreadLocal里有ThreadLocalMap这么个内部类,每个Thread对象维护着一个ThreadLocalMap的引用,ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储

  • 调用ThreadLocal的set方法,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值Value是传递进来的对象
  • 调用ThreadLocal的get方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象

ThreadLocal本身并不存储值(ThreadLocal是一个壳子),它只是自己作为一个key来让线程从ThreadLocalMap获取value。正因为这个原理,所以ThreadLocal能够实现数据隔离,获取当前线程的局部变量值,不受其他线程影响

ThreadLocal为什么要用弱引用?(重点!)

当function方法执行完毕后,栈帧销毁引用tl也就没有了,但是此时线程的ThreadLocalMap里某个entry的key引用还指向这个对象,若这个key引用是强引用,就会导致key指向的ThreadLocal对象及v指向的对象不能被gc回收,造成内存泄漏;若这个key引用是弱引用,就会大概率减少内存泄漏点的问题(还有一个key为的雷)。使用弱引用,就可以使ThreadLocal对象在方法执行完毕后顺利被回收且Entry的key引用指向为null

key为null的entry

当我们为threadLocal变量赋值,实际上就是当前的Entry(threadLocal实例为key,值为value)往这个threadLocalMap中存放。Entry中的key是弱引用,当threadLocal外部强引用被置为null,那么系统GC的时候,根据可达性分析,这个threadLocal实例就没有任何一条链路能够引用到它,这个ThreadLocal势必会被回收。这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链: Thread Ref -> Thread -> ThreadLocalMap -> Entry -> value永远无法回收,造成内存泄漏。当然,如果当前thread运行结束,threadLocal,threadLocalMap,Entry没有引用链可达,在垃圾回收的时候都会被系统进行回收。但在时机使用中我们有时候会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建线程的时候,为了服用线程是不会结束的,所以threadLocal内存泄漏就值得我们小心

因此弱引用不能100%保证内存不泄漏,我们要在不实用某个ThreadLocal对象后,手动调用remove方法来删除它,尤其是在线程池中,不仅仅是内存泄漏的问题,因为线程池中的线程是复用的,意味着这个线程的ThreadLocal对象也是重复使用的,如果不手动调用remove方法,那么后面的线程就有可能获取到上个线程遗留下来的value值,造成BUG

在ThreadLocal的声明收起里,针对threadLocal存在的内存泄漏的问题,都会通过expungeStaleEntry,cleanSomeSlots,replaceStaleEntry这个三个方法清理掉key为null的脏entry

总结

  • ThreadLocal.withInitial(() -> 初始值)否则可能会报空指针异常
  • 建议把ThreadLocal修饰为static。ThreadLocal能实现了线程的数据隔离,不在于它自己本身,而在于Thread的ThreadLocalMap,所以ThreadLocal可以只初始化一次,只分配一块存储空间足以了,没必要作为成员变量多次被初始化
  • 用完记得手动remove
  • ThreadLocal并不解决线程间共享数据的问题
  • ThreadLocal适用于变量在线程间隔离且在方法间共享的场景
  • ThreadLocal通过隐式的不同线程内创建独立实例副本避免了实例线程安全的问题
  • 每个线程持有一个只属于自己的饿专属Map并维护了ThreadLocal对象与具体实例的映射,该Map由于只被持有它的线程访问,故不存在线程安全以及锁的问题
  • ThreadLocalMap的Entry对ThreadLocal的引用为弱引用,避免了ThreadLocal对象无法被回收的问题
  • 都会通过expungeStaleEntry,cleanSomeSlots,replaceStaleEntry这三个方法回收键为null的Entry对象的值记忆Entry对象本身从而防止内存泄漏,属于安全加固的方法

对象内存布局

在HotSpot虚拟机里,对象在堆内存中的存储部署可以划分为三个部分:

  • 对象头
存储内容 标志位 状态
对象哈希码,对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空(不需要记录信息) 11 GC标记
偏向线程 ID,偏向时间戳,对象分代年龄 01 可偏向

在64位系统中, Mark Word占了8个字节类型指针占了8个字节,一共是16个字节

  1. 对象标记Mark Word。默认存储对象的HashCode,分代年龄和锁标志位等信息。这些信息都是与对象自身定义无关的数据,所以MarkWord被设计成一个固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己存储空间,也就是说在运行期间MarkWord里存储的数据会随着锁标志位的变化而变化
  2. 类元信息(又叫类型指针)。对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
  • 实例数据。存放累的属性(Field)数据信息,包括父类的属性信息
  • 对齐填充。虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐这部分内存按8字节补充对齐

压缩指针

先使用java -XX:+PrintCommandLineFlags -version命令查看虚拟机所有配置

默认启动了压缩指针-XX:+UseCompressedClassPointers一个对象 = 12 + 4(对齐填充) = 16字节
如果手动关闭了压缩指针-XX:-UseCompressedClassPointers一个对象 = 8 + 8 = 16字节

Synchronized锁升级

无锁

初始状态,一个对象被实例化后,如果还没有被任何线程竞争锁,那么它就为无锁状态(001)

偏向锁

偏向锁是单线程竞争,当线程A第一次竞争到锁时,通过操作修改Mark Word中的偏向线程ID,偏向模式。如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要进行同步。当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁。

理论落地

在实际应用运行过程中,锁总是同一个线程持有,很少发生竞争,也就是说锁总是被第一个占用它的线程拥有这个线程就是锁的偏向线程。那么只需要在锁第一次被拥有的时候,记录下偏向线程ID,这个样偏向线程就一直持有着锁(后续这个线程进入和退出加了同步锁的代码块时,不需要再次加锁和释放锁,而是直接去检查锁的MarkWord里面是不是放的自己的线程ID)

如果相等,表示偏向锁时偏向于当前线程,就不需要再尝试获取锁了,直到竞争发生才释放锁
如果不等,表示发生了竞争,锁已经不是总是偏向于同一个线程了,这个时候会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID
竞争成功,表示之前的线程不存在了,MarkWord里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁
竞争失败,这时候可能需要升级变为轻量锁,才能保证线程间公平竞争锁
注意: 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的

偏向锁的启动

  • 使用命令的方式开启偏向锁-XX:+UseBiasedLocking
  • 因为偏向锁默认是开启的但是它有4秒的延迟,所以我们可以让线程先sleep4秒(Thread.sleep(4))

偏向锁的撤销

偏向锁使用一种等待竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。撤销需要等待全局安全点(该时间点上没有字节码正在执行),同时检查持有偏向锁的线程是否还在执行:

  • 第一个线程正在执行synchronized方法(处于同步块),它还没有执行完,其他线程来抢夺,偏向锁会被取消并出现锁升级,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量锁
  • 第一个线程执行完成synchronized方法(退出同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向

变更

Java逐步废弃偏向锁

轻量锁

多线程竞争,但是任意时刻最多只有一个线程竞争,即不存在锁竞争太过激烈,也就没有线程阻塞。轻量级锁是为了在线程近乎交替执行同步块时提高性能。

  • 主要目的: 在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋不行再升级
  • 升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量锁

加入线程A已经拿到锁,这时线程B又来抢该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已是偏向锁,而线程B在争抢时发现对象头MarkWord中的线程ID不是线程B自己的线程ID(是线程A),那线程B就会进行CAS操作希望获得锁。此时线程B操作中有两种情况:

  • 如果锁获取成功: 直接替换MarkWord中线程ID为B自己的ID(A -> B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程被释放了锁),该锁会保持偏向锁状态,A线程Over,B线程上位
  • 如果锁获取失败: 则偏向锁升级为轻量锁(设置偏向标识为0并设置锁标志位为00),此时轻量锁由原持有偏向锁的线程持有,继续执行其同步嗲吗,而正在竞争的线程B会进入自旋等待获得该轻量锁

轻量锁的加锁

JVM会为每个线程在当前线程的栈帧中创建用于存储记录空间,官方称为Displaced Mark Word,若一个线程获得锁时发现是轻量锁,会把锁的MarkWord复制到自己的Displaced Mark Word里面,然后线程尝试用CAS将锁的MarkWord替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,标识MarkWord已经被替换成了其他线程的锁记录,说明在于其他线程竞争锁,当前线程就尝试使用自旋来获得锁。自旋CAS:不断尝试去获取锁,能不升级就不升级,尽量不要阻塞

轻量锁的释放

在释放锁时,当前线程会使用CAS操作将Displaced Mark WOrd的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功,如果有其他线程因为自旋多次当值轻量锁升级成为了重量级锁,那么CAS操作失败,此时会释放锁并唤醒被阻塞的线程

自旋标准

  • Java6之前: 默认启动,默认情况下自旋的次数是10次或者是超过CPU核数的一半(-XX:PreBlockSpin=10来修改)
  • Java6之后: 线程如果成功了,那下次自旋的最大次数会增加,因为JVM认为既然上次成功了,那么这一次也很大概率会成功。反之,如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,避免CPU空转

轻量锁与偏向锁的区别和不同

争夺轻量锁失败时,自旋尝试抢占锁
轻量锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁

重量锁

有大量的线程参与锁的竞争,冲突性很高。Java中synchronized的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter指令,在结束位置插入monitor exit指令

当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果取到了,即获得到了锁,会在Monitor的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor

锁升级后与HashCode的关系

  • 在无锁状态下,Mark Word 中可以存储对象的identity hash code值。当对象的hashCode()方法第一次被调用时,JVM会生成对应的identity hash code值并将该值存储到Mark Word中。
  • 对于偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值(可以理解为时间戳)覆盖identity hash code所在位置。如果一个对象的hashCode()方法已经被调用过一次后,这个对象不能被设置偏向锁。因为如果可以的话,那Mark Word中的identity hash code必然会被偏向线程ID给覆盖,这就会造成同一个对象前后两次调用hashCode()方法得到的结果不一致
  • 升级为轻量锁时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间用于存储对象的Mark Word拷贝,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存,哈希吗和GC年龄自然保存在此,释放锁后会将这些信息写回对象头
  • 升级为重量级锁后,Mark Word保存的重量级锁指针,代表重量级锁的ObjectMonitor类里有字段记录非加锁状态下的Mark Word,锁释放后也会将信息写回对象头

锁的优缺点对比

  • 偏向锁
    1. 优点: 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距
    2. 缺点: 如果线程间存在锁竞争,会带来额外的锁撤销的消耗
    3. 适用场景: 适用于只有一个线程访问同步块场景
  • 轻量级锁
    1. 优点: 竞争的线程不会阻塞,提高了程序的响应速度
    2. 缺点: 如果始终得不到锁竞争的线程,使用自旋会消耗CPU
    3. 适用场景: 追求响应时间,同步块执行速度非常快
  • 重量级锁
    1. 优点: 线程竞争不实用自旋,不会消耗CPU
    2. 缺点: 线程阻塞,响应时间缓慢
    3. 适用场景: 追求吞吐量,同步块执行速度较长

synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于独享头的MarkWord来实现的。JDK1.6之前synchronized使用的是重量级锁,JDK之后进行了优化,拥有了无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,而不是无论什么情况都是用重量级锁

偏向锁: 适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁

轻量级锁: 适用于竞争较不激烈的情况下(这和乐观锁的使用范围类似),存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用CPU资源但是相对重量级锁还是很高效

重量级锁适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁

锁消除

JIT(Just In Time Compiler)一般翻译为即时编译器

从JIT角度看相当于无视它,synchronized(o)不存在了,这个锁对象并没有被公用扩散到其他线程,极端的说就是根本没有加这个锁对象的底层机器码,笑出了锁的使用

锁粗化

加入方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请使用即可,避免次次的申请和释放锁,提升了性能

AQS

是用来实现锁或者其他同步器组件的公共基础部分的抽象实现,是重量级基础框架整个JUC体系的基石,主要用于解决分配给谁的问题。整体就是一个抽象的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量标识持有锁的状态

源码分析

公平锁和非公平锁的lock()方法唯一区别就在于公平锁在获取同步状态时多了一个限制条件: hasQueuePredecessors(),hasQueuePredecessors是公平锁加锁时判断等待队列中是否存在有效节点的方法

  • tryAcquire
  • addWaiter(Node.EXCLUSIVE)
  • acquireQueue(addWaiter(Node.EXCLUSIVE), arg)

更多具体细节请移步AQS源码体系

读写锁

读写锁定义为一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程

ReentrantReadWriteLock

它只允许读读共存,而读写和写写依然是互斥,大多实际场景是读读线程间并不存在互斥关系,只有读写线程或写写线程间的操作需要互斥的,因此引入ReentrantReadWriteLock。一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁,也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行,只有在读多写少情境之下,读写锁才具有较高的性能体现

锁降级

将写入锁降级为读锁(类似于Linux文件读写权限理解,就像写权限要高于读权限一样),锁的严苛程度变强较升级反之叫降级,写锁的降级,降级成了读锁

  • 如果同一个线程持有了写锁,在没有释放的情况下,它还可以继续获得读锁,这就是写锁的降级,降级成了读锁
  • 规则惯例,先获取写锁,然后获得读锁,再释放写锁的次序
  • 如果释放了写锁,那么就完全转换为读锁

写锁和读锁是互斥的(这里的互斥是指线程间的互斥,当前线程可以获取到写锁又可以获取到读锁,但是获取到了读锁不能继续获取写锁),这是因为读写锁要保持写操作的可见性,因为如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作。即ReentrantReadWriteLock读过程中不允许写,只有等线程都释放了读锁,当前线程才可以获取写锁,也就是写锁必须等待,这是一种悲观的读锁,人家还在读,你就先别写,省的数据乱

锁饥饿

ReentrantReadWriteLock实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,假如当前1000个线程,999个读,1个写,有可能999个读取线程长时间抢到了锁,那一个写线程就悲剧了。因为当前线程有可能会一直存在读锁而无法获得写锁,根本没机会写

StampedLock

所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为零标识获取失败,其余都表示成功,所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致。StampedLock是不可重入的,如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁。StampedLock有三种访问模式:

  • Reading(读模式悲观): 功能和ReentrantReadWriteLock的读锁类似
  • Writing(写模式): 功能和ReentrantReadWriteLock的写锁类似
  • Optimistic reading(乐观读模式): 无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式