并发编程02Synchronized

并发编程之synchronized&Lock&AQS详解

为什么加锁

加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问)

显示锁与隐示锁

image-20211024180413679

锁的分类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Unsafe工具类
*/
public class UnsafeInstance {

public static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

synchronized使用与原理

使用方式

1、类的静态方法,锁当前类对象

2、实例方法,锁当前实例对象

3、同步代码块,锁代码里的对象

底层原理

​ JVM内置锁通过synchronized使用,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。简单的说时基于JMM8大操作里的lock和unlock,MonitorEnter和MonitorExit

Monitor

每个对象都有一个自己的Monitor(监视器锁)

锁的定义

image-20211024220009143

加锁过程

image-20211024215853709

对象内存结构

image-20211024222345420

image-20211024231911266

  1. 对象头(哈希code、锁状态、当前持有线程、jvm年龄、偏向变量、MetaDate元数据指针)
  2. 实例数据
  3. 对齐填充

实例对象内存存储在哪?线程逃逸分析

如果实例对象存储在堆时:实例对象内存存在堆区,实例引用存在栈上,实例的元数据class存在元空间中
如果存在逃逸分析,对象实例内存可能开辟在线程栈上

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
public class StackAllocTest {

/**
* 进行两种测试
* 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
* VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 开启逃逸分析
* VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 执行main方法后
* jps 查看进程
* jmap -histo 进程ID
*
*/

public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 500000; i++) {
alloc();
}
long end = System.currentTimeMillis();
//查看执行时间
System.out.println("cost-time " + (end - start) + " ms");
try {
Thread.sleep(100000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}


private static TulingStudent alloc() {
//Jit对编译时会对代码进行 逃逸分析
//并不是所有对象存放在堆区,有的一部分存在线程栈空间
TulingStudent student = new TulingStudent();
return student;
}

static class TulingStudent {
private String name;
private int age;
}
}

通过jmap -histo pid 查看jvm内存实例情况

image-20211024232528178

锁的优化与升级

锁的粗化(Lock Coarsening)

锁的粗化是JVM为了减少线程获取锁和释放锁的次数,从而优化性能的一种策略。当JVM检测到一连串连续的对同一锁进行的加锁和解锁操作时,它会将这一连串的锁操作合并成一个较大的锁块,从而减少线程获取锁和释放锁的开销。

锁的消除(Lock Elimination)

锁的消除是JVM在运行时根据逃逸分析(Escape Analysis)的结果,确定一个对象不会被外部线程访问到,从而将其同步操作消除的一种优化策略。如果JVM通过逃逸分析发现某个同步块中的对象不会“逃逸”到同步块之外,即该对象不会被其他线程访问到,那么JVM就可以安全地消除这个同步块中的锁操作。

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
public class Test {
StringBuffer stb = new StringBuffer();


public void test1(){
//jvm的优化,锁的粗化
stb.append("1");

stb.append("2");

stb.append("3");

stb.append("4");
}

/**
* 锁的消除
*/
public void test2(){
//jvm的优化,JVM不会对同步块进行加锁
//因为 new Object()这种匿名对象不会被别的线程使用到
synchronized (new Object()) {
//伪代码:很多逻辑
//jvm是否会加锁?
//jvm会进行逃逸分析
}
}

public static void main(String[] args) {
Test test = new Test();
}
}

JVM内置锁的优化升级过程

image-20211024234942906

偏向锁

偏向锁,它会偏向于第一个访问锁的线程

  • 如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
  • 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。偏向锁通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致STW(stop the word)操作;

轻量级锁(自旋锁)

自旋锁:自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

  • 在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁

重量级锁

重量级锁:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起,等待将来被唤醒。对比JDK1.6之前,synchronized直接加重量级锁,现在很明显现在得到了很好的优化

依靠java内置对象monitor实现的锁

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

锁升级场景

场景1: 经常只有某一个线程来加锁。

  • 加锁过程:也许获取锁的经常为同一个线程,这种情况下为了避免加锁造成的性能开销,加偏向锁
  • 偏向锁的执行流程如下:
    • 1、线程首先检查该对象头的线程ID是否为当前线程;
    • 2、A:如果对象头的线程ID和当前线程ID一直,则直接执行代码;B:如果不是当前线程ID则使用CAS方式替换对象头中的线程ID,如果使用CAS替换不成功则说明有线程正在执行,存在锁的竞争,这时需要撤销偏向锁,升级为轻量级锁
    • 3、如果CAS替换成功,则把对象头的线程ID改为自己的线程ID,然后执行代码。
    • 4、执行代码完成之后释放锁,把对象头的线程ID修改为空。

场景2: 有线程来参与锁的竞争,但是获取锁的冲突时间很短。

  • 当开始有锁的竞争了,那么偏向锁就会升级到轻量级锁;
  • 线程获取锁出现冲突时,线程必须做出决定是继续在这里等,还是先去做其他事情,等会再来看看,而轻量级锁的采用了继续在这里等的方式。当发现有锁竞争,线程首先会使用自旋的方式循环在这里获取锁,因为使用自旋的方式非常消耗CPU。当一定时间内通过自旋的方式无法获取到锁的话,那么锁就开始升级为重量级锁了。

场景3: 有大量的线程参与锁的竞争,冲突性很高。

  • 当获取锁冲突多,时间越长的时候,线程肯定无法继续在这里死等了,所以只好先挂起,然后等前面获取锁的线程释放了锁之后,再开启下一轮的锁竞争,而这种形式就是我们的重量级锁。

HashMap

HashMap 1.7 = 数组 + 单向链表
扩容时可能会产生死锁,多线程扩容时链表倒插可能产生闭环

ConcurrentHashMap 1.7 = Segment数组(继承ReentrantLock) + hashEntry数组 + 链表,从而实现分段锁,支持并发

image-20210927235446858

HashMap 1.8 = 数组 + 单向链表。扩容时不会倒插,而是采用高低位插入,Node的hash 值&(扩容后-1)最高位=1则扩容后下标=扩容前大小+原下标,否则=原下标

ConcurrentHashMap 1.8 = Node数组 + 链表,区别在于每次插入,都synchronize第一个节点,相同于锁一条链表,并且通过CAS的算法插入每个链表的第一个阶段,从而达到并发,锁的粒度较小,灵活

image-20210928000406955

线程池原理与解读

线程池的优势:

1、重用存在的线程,减少线程创建,消亡的开销(开辟的空间处理,操作内核空间时间的消耗),提高性能

2、提高响应速度(直接取,无需等待)

3、提高对线程的管理,方便进行统一分配、调优和监控

Executor框架

线程池的创建

线程池的工作原理

核心线程

阻塞队列

非核心线程

拒绝策略

image-20210928233525697

线程池重点属性

线程池状态流转

fork/join 分发线程池

定时任务/定时任务线程池