一. 使用
synchronized可用来修饰实例方法,静态方法和代码块,其作用是为了保证被修饰的方法或代码块在同一时间内,只有一个线程可以执行。那么我们先看看如何使用,因重点不是介绍使用,会快速而简单的介绍下。
public class TestSynchronized {
private static int count = 0;
public synchronized void increase() {
for (int i = 0; i < 100000; i++) {
count++;
}
}
public static synchronized void staticIncrease() {
for (int i = 0; i < 100000; i++) {
count++;
}
}
public void thisIncrease() {
synchronized (this) {
for (int i = 0; i < 100000; i++) {
count++;
}
}
}
public void classIncrease() {
synchronized (TestSynchronized.class) {
for (int i = 0; i < 100000; i++) {
count++;
}
}
}
public static void main(String[] args) throws InterruptedException {
// synchronized修饰实例方法时,锁对象为实例对象,不同的实例对象互不干扰
test1();
test2();
// synchronized修饰静态方法时,锁对象为类对象,不受实例对象影响
test3();
// synchronized 修饰代码块时,如果用this,作为锁对象,锁的就是实例对象。和实例方法类似
test4();
test5();
//synchronized 修饰代码块时,如果用class作为锁对象,锁的就是类对象。使用多个实例对象调用,
// 也都是同一把锁
test6();
test7();
}
private static void test7() throws InterruptedException {
TestSynchronized testSynchronized1 = new TestSynchronized();
TestSynchronized testSynchronized2 = new TestSynchronized();
Thread thread1 = new Thread(() -> testSynchronized1.classIncrease());
Thread thread2 = new Thread(() -> testSynchronized2.classIncrease());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count ->" + count);
}
private static void test6() throws InterruptedException {
TestSynchronized testSynchronized = new TestSynchronized();
Thread thread1 = new Thread(() -> testSynchronized.classIncrease());
Thread thread2 = new Thread(() -> testSynchronized.classIncrease());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count ->" + count);
}
private static void test5() throws InterruptedException {
TestSynchronized testSynchronized1 = new TestSynchronized();
TestSynchronized testSynchronized2 = new TestSynchronized();
Thread thread1 = new Thread(() -> testSynchronized1.thisIncrease());
Thread thread2 = new Thread(() -> testSynchronized2.thisIncrease());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count ->" + count);
}
private static void test4() throws InterruptedException {
TestSynchronized testSynchronized = new TestSynchronized();
Thread thread1 = new Thread(() -> testSynchronized.thisIncrease());
Thread thread2 = new Thread(() -> testSynchronized.thisIncrease());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count ->" + count);
}
private static void test3() throws InterruptedException {
TestSynchronized testSynchronized1 = new TestSynchronized();
TestSynchronized testSynchronized2 = new TestSynchronized();
Thread thread1 = new Thread(() -> testSynchronized1.staticIncrease());
Thread thread2 = new Thread(() -> testSynchronized2.staticIncrease());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count ->" + count);
}
private static void test2() throws InterruptedException {
TestSynchronized testSynchronized1 = new TestSynchronized();
TestSynchronized testSynchronized2 = new TestSynchronized();
Thread thread1 = new Thread(() -> testSynchronized1.increase());
Thread thread2 = new Thread(() -> testSynchronized2.increase());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count ->" + count);
}
private static void test1() throws InterruptedException {
TestSynchronized testSynchronized = new TestSynchronized();
Thread thread1 = new Thread(() -> testSynchronized.increase());
Thread thread2 = new Thread(() -> testSynchronized.increase());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count ->" + count);
}
}
1. 修饰实例方法
increase()方法是类的实例方法,那么我们看下test1(), test2()方法的执行结果
count ->200000
count ->395058
结论:实例方法:锁的是实例对象,在不同线程用同一个实例对象才能保证synchronized的作用
2. 修饰静态方法
staticInrease()方法是类的静态方法,我们先看下test3()方法执行的结果(基本不会使用实例对象调用静态方法,此处只是为了测试而已)
count ->200000
结论:静态方法:锁的是类对象(class),在不同线程用不同的实例对象,也能保证synchronized的作用,因为class对象在方法区中只有一份
3. 修饰代码块
修饰代码块,分为两种情况,一中是thisIncrease()方法,synchronized(实例对象),this是当前类的实例对象;另一种是classIncrease()方法,synchronized(类)。我们先看第一种,执行test4(),test5()方法的结果
count ->200000
count ->317112
结论:修饰代码块时,synchronized(实例对象):锁的是实例对象,与修饰实例方法一致
再看第二种,执行test6(),test7()的结果:
count ->200000
count ->400000
结论:修饰代码块时,synchronized(类对象):锁的类对象,与修饰静态方法一致
4.用法总结
synchronized在使用上分为三种类型,分别是修饰实例方法,静态方法,代码块;从锁的角度来看,分为两种,分别是锁实例对象和锁类对象
二.synchronized在class文件中的实现
我们就直接把上面的测试代码的class文件反编译出来看看,太长了,我们只截取需要的信息
// increase在flags有一个ACC_SYNCHRONIZED标记位
public synchronized void increase();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
// staticIncrease同理也是有一个 ACC_SYNCHRONIZED标记位
public static synchronized void staticIncrease();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
// thisIncrease方法没有ACC_SYNCHRONIZED的标记位了
// 但是可以观察到monitorenter,monitorexit 这看起来就像是锁操作了。
// classIncrease与此类似,就不多说
public void thisIncrease();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: iconst_0
5: istore_2
6: iload_2
7: ldc #2 // int 100000
9: if_icmpge 26
12: getstatic #3 // Field count:I
15: iconst_1
16: iadd
17: putstatic #3 // Field count:I
20: iinc 2, 1
23: goto 6
26: aload_1
27: monitorexit
28: goto 36
31: astore_3
32: aload_1
33: monitorexit
34: aload_3
35: athrow
36: return
结论:synchronized在修饰方法时,在class文件中是通过ACC_SYNCHRONIZED标记的,修饰代码块时,是通过monitorenter和monitorexit指令来标记的
ACC_SYNCHRONIZED
synchronized修饰方法时是隐式
的,在调用方法时,会去检查运行时常量池的method_info中的ACC_SYNCHRONIZED标记位,如果存在的话,执行线程会先获取monitor锁(英文是:enter the monitor),执行方法,释放monitor锁(exit the monitor)。若同步方法抛出异常,会在抛出之前先释放monitor锁。
MonitorEnter&MonitorExit
每一个对象都有一个对应的monitor,一旦这个monitor被拥有之后,就相当于被锁住。当线程执行monitorenter指令时,就尝试获取对应的monitor。
- 每个monitor维护一个被拥有次数的计数器,没有时是0,当一个线程获取monitor后,计数器自增为1。
- 当前线程再次获取此monitor时,计数器加1;当不同线程尝试获取此monitor时,会被阻塞。
- 当同一个线程释放monitor时,计数器减1,当计数器为0时,monitor被释放,其他线程可尝试获取。
- 细心一点的话,可以发现前面方法的字节码中有两个monitorexit指令,其作用是为了保证在代码发生异常时,也可以退出当前锁状态。
结论:字节码角度上,synchronized的实现只区分修饰方法和修饰代码块,分别对应ACC_SYNCHRONIZED标记位和MonitorEnter&MonitorExit指令,而从这两个底层分析,其实都是依靠一个Monitor对象
三. Monitor&重量级锁?
monitor叫做管程,在JVM中的实现是ObjectMonitor,我们来看下ObjectMonitor的实现原理。
先看下ObjectMonitor的数据结构,代码是c++的文件,我这里是从网上截取下来的。
_owner //拥有当前锁的线程
_count //_owner线程获取锁的次数,synchronized是可重入锁,也就是在锁的代码块中仍可以加锁
_WaitSet // 存放处于wait状态的线程队列
_EntryList
CXQ(ContentionList)
运行流程如下图(来源于网络):此流程感觉比网络上博客写得更为详细,感兴趣的可以在文章底部查看链接访问原文。
这里也是简述线程获取monitor的流程:
- 想要获取monitor的线程通过CAS尝试获取锁,若失败的话,尝试用自旋的方式获取,若仍获取失败,进入CXQ,并阻塞;
- 竞争成功,将 _owner赋值为此线程;
- 若在代码中调用了wait()方法,则此线程进入_waitSet,此时 _EntryList中其他线程可以获取monitor;
- 若在代码中调用了notify()/notifyAll()方法,则会唤醒 _waitSet中的线程,放入Entry或CXQ尝试竞争锁;
- 当同步代码执行结束后,将_owner设为null,退出。让其他线程竞争。
结论:上述流程就是重量级锁,在JDK1.6以前synchronized的实现就是通过重量级锁实现的
其缺点是性能很差,其原因是monitor的底层实现是靠操作系统的Mutex Lock来实现,而这涉及到用户态和内核态之间的切换(当执行代码时是用户态,当遇到synchronized时就需要切换到内核态),而这切换过程消耗时间。
在JDK1.6及以后,JVM为了提高synchronized的效率,对其做了很多优化,其中最重要的就是锁升级,这才说到我们的重点。
四.Synchronized的优化-锁升级
我们在前面学习了synchronized在class文件中的实现,在JVM和操作系统中重量级锁ObjectMonitor的实现,也得知其性能很差,那么JVM的开发者就对此做了优化,那就是锁升级策略。在说锁升级策略前,必须要学习的是对象在堆中的布局结构。因为其布局结构中就保存了锁的相关信息。
对象在内存的数据结构
对象在内存的数据基本如下图,基本分为三个部分,分别是对象头信息,实例数据,对齐字节。
- 对象头信息分为Mark word和Class Pointer。Class Pointer就是指向方法区中的类对象。
- Instance Data就是实例对象的实例变量的数据。
- 对齐字节是JVM要求对象起始地址必须是 8 字节的整数倍,若前面不是8字节的倍数,则就会加上对齐字节。
Mark Word
把Mark Word单独拎出来的原因是锁信息就是存储在Mark Word里的。
Mark Word的长度:在32位JVM中是32bit,在64位JVM中是64bit。
我们来写个代码看下
public static void main(String[] args) {
Object object = new Object();
//ClassLayout是jol-cli jar包内的类,openJdk工具 :
// 地址:https://mvnrepository.com/artifact/org.openjdk.jol/jol-cli
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
执行结果:
我的JVM是64位,所以前8个字节是Mark Word,8-12字节是class pointer(原本也是8字节,JVM默认开启压缩优化,变成4字节,参数为-XX:+UseCompressedOops),12-16字节就是补齐字段。因为我仅仅是new Object,所以没有实例数据。
那我们现在来看下Mark Word里面的具体内容,Mark Word中不同的标记和Synchronized的锁的对应关系,如下图(来源于网络),32位和64位的存储内容的结构基本一致,仅仅是大小有些区别。我们后续在测试过程中会对应到表中的标记位来确定当前的锁情况。
锁概念介绍
锁状态分为无锁,偏向锁,轻量级锁,重量级锁。升级过程也就是:无锁 -> 偏向锁 -> 轻量级锁->重量级锁。
无锁:当第一次创建对象,且JVM未开启偏向锁时,且未对此对象进行加锁操作时的状态
偏向锁:当第一次创建对象,JVM开启偏向锁时,尚未对此对象进行加锁操作,叫做匿名偏向(头信息中线程id=0);当对此对象进行加锁操作时,使用CAS操作把使用当前线程id赋值到mark word中,此时是偏向锁开启状态
轻量级锁:当另外一个线程同时请求此对象锁时,偏向锁会进行锁撤销,在safe point时,查看持有锁的偏向线程是否存活,如果存活且还在同步块中,则升级为轻量级锁,原偏向线程继续持有,当前线程进入锁升级逻辑。如果偏向线程不存活或不在同步块中,则改为无锁状态,再升级为轻量级锁。
重量级锁:当线程1持有轻量级锁时,线程2也来请求获取锁,线程1的持有,线程2会请求失败,此时线程2会进入自旋锁状态。当线程2 自旋次数超过一定次数,或者此时线程3也来请求获取锁,轻量锁就会膨胀为重量级锁。
无锁&偏向锁
/**
* 偏向锁延迟: 因在JVM启动时需要创建许多许多后台线程,GC,VM Thread等
* 会有许多的同步操作,所以在JVM启动是延迟开启偏向锁的,默认是延迟4s。
* -XX:BiasedLockingStartupDelay=4000
*/
private static void testPXDelay() {
//无锁状态
Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//匿名偏向状态
Object object1 = new Object();
System.out.println(ClassLayout.parseInstance(object1).toPrintable());
}
执行结果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f df 17 (00101000 00001111 11011111 00010111) (400494376)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f df 17 (00101000 00001111 11011111 00010111) (400494376)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
object在创建时JVM是关闭偏向锁的,未对object做锁操作,所以其是无锁状态,状态就是001。
00000001 00000000 00000000 00000000
00000000 00000000 00000000 00000000红色部分为锁状态,绿色部分是对象分代年龄,黄色部分是hashcode(其为0的原因是没有调用过hashcode方法,若调用过则赋值)
测试下hashcode:
Object object = new Object();
System.out.println(Long.toBinaryString(object.hashCode()));
System.out.println(ClassLayout.parseInstance(object).toPrintable());
//执行结果(只截取需要的信息了,打印结果可以看到信息是反着排序的):
hashcode->10101010000001110000110011101
00000001 10011101 11100001 01000000
00010101 00000000 00000000 00000000
偏向锁延迟
偏向锁延迟: 因在JVM启动时需要创建许多许多后台线程,GC,VM Thread等,会有许多的同步操作,所以在JVM启动是延迟开启偏向锁的,默认是延迟4s。设置参数: -XX:BiasedLockingStartupDelay=4000
object1是在JVM启动5秒后创建的,此时JVM是开启了偏向锁的。所以object1是开启偏向锁的,锁状态为101。但因为其未被其他线程获取锁,所以是匿名偏向状态。那我们用一个线程来尝试锁一下。
Thread thread = new Thread(() -> {
synchronized (object1) {
System.out.println(ClassLayout.parseInstance(object1).toPrintable());
}
});
thread.start();
System.out.println("--->" + Long.toBinaryString(thread.getId()));
执行结果:
--->1100
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 a0 9a 59 (00000101 10100000 10011010 01011001) (1503305733)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f d0 17 (00101000 00001111 11010000 00010111) (399511336)
12 4 (loss due to the next object alignment)
结果分析:当我们未锁object1时,mark word中除了锁状态以外都是0,当我们synchronized(object1)后,mark word就有值了。表示存储了线程id,但是呢我发现和我通过thread.getId()的值是不一样的。这不太清楚是何原理。先放在这里吧。
总结:以上分析了无锁状,匿名偏向,偏向锁三种状态。其升级过程:当JVM未开启偏向锁时,创建对象,即是无锁状态;开启偏向锁后,创建对象,即是匿名偏向状态;一旦某个线程尝试获取锁时,便会尝试把线程Id通过CAS操作贴到对象的mark word里,成功后便是偏向锁状态
原因分析:为何会有偏向锁呢?是因为HotSpot开发者发现在很多情况下,加synchronized的方法或代码其实只会有一个线程访问。其实无需从用户态切换到内核态(重量级锁),那么就产生了偏向锁。举个列子,比如我们使用stringbuffer,在下面的代码情况下,其实完全无需申请锁。
private void testStringBuffer() {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(1).append(2);
System.out.println(stringBuffer.toString());
}
轻量级锁
轻量级锁适应的场景是线程交替执行的情况,如果两个线程同时竞争同一把锁的话,会导致轻量级锁膨胀为重量级锁。
先看产生轻量级锁的情况,测试代码
private static void testQL() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object object = new Object();
Thread thread1 = new Thread(() -> {
synchronized (object){
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
try {
//thread1退出同步代码块,且没有死亡
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
synchronized (object){
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
});
thread1.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//线程1执行结束之后,线程2开始执行
thread2.start();
}
执行结果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 88 87 5b (00000101 10001000 10000111 01011011) (1535608837)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f b4 17 (00101000 00001111 10110100 00010111) (397676328)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) a0 f2 5a 5d (10100000 11110010 01011010 01011101) (1566241440)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f b4 17 (00101000 00001111 10110100 00010111) (397676328)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
结果分析:在线程1执行时,仍然是偏向锁,当线程1执行完成之后,线程2执行,偏向锁就会升级成轻量锁。
两个线程同时竞争的情况
private static void testZL() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object object = new Object();
Thread thread1 = new Thread(() -> {
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
});
Thread thread2 = new Thread(() -> {
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
});
//线程1,2同时执行
thread1.start();
thread2.start();
}
执行结果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 1a ed 49 58 (00011010 11101101 01001001 01011000) (1481239834)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f ab 17 (00101000 00001111 10101011 00010111) (397086504)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 1a ed 49 58 (00011010 11101101 01001001 01011000) (1481239834)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 0f ab 17 (00101000 00001111 10101011 00010111) (397086504)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
结果分析:object的锁状态直接就是10了,故当两个线程直接竞争同一把锁时,就直接升级为重量级锁。
锁升级流程
偏向锁流程图:
轻量级锁流程图:
五. CAS
在前面的过程中,不断提到CAS操作。那我们来简单学习一下CAS操作是什么吧。CAS: Compare And Swap。是为了防止多线程修改同一数据的优化操作。为了避免多线程的同时修改,在每次写入数据的时候,都会拿原来的值和内存中的值对比一下,如果相同,认为没改过,那就可以把计算后的数据写入到当前内存中。如果不同,则认为修改过,那么重新读取当前值,再次重复上述操作。这就是CAS。
ABA问题:当拿原来的值和内存的值比较时,可能存在一种情况是许多线程修改过此数据,在多次修改后变回来原来的值,在比较时发现值相同,但实际上是修改过的。这就是ABA问题。解决方案是添加一个版本号,每次改动都修改一次版本号。在比较时不仅比较值,也比较版本号。
流程图如下:
六. 总结
一个小小的关键字synchronized,其背后原理和实现竟如此的复杂。不得不佩服JVM的设计者和开发者。我也是边学习边记录,可能存在理解错误,请批判性学习。
使用
修饰静态方法,实例方法,代码块(synchronized实例对象或类对象)。
Class文件
- 方法:在常量池的方法信息中添加ACC_SYNCHRONIZED标记
- 代码块:在执行指令中添加monitorenter, monitorexit指令,其中会有两个monitorexit,是为了保证在同步代码块抛出异常时,可以退出锁的占用。
锁升级
JDK1.6以前synchronized都是重量级锁,其每次锁操作都需要从用户态切换到核心态,效率极低。
为了优化synchronized性能,引入无锁,偏向锁,轻量级锁,重量级锁的状态。其区分不同的锁是依靠实例对象对象头中的mark word。
无锁:在JVM关闭偏向锁状态,或在JVM延迟开启偏向锁的时间段内创建的对象,且没有synchronized操作,是无锁状态;
偏向锁: 其引入原因是多数情况下只有一个线程执行此同步块代码,在JVM开启偏向锁时,创建的对象首先匿名偏向状态。当线程A将线程id贴到对象的mark word上,表明此线程A占有了该对象的偏向锁。
轻量级锁:虽然存在多线程竞争,但同步代码执行速度很快,或基本处于多个线程轮流执行的场景时,使用轻量级锁。当线程B请求相同对象的锁时,发现线程A的id已经贴在mark word上时,就会升级为轻量级锁(还有一些其他场景,在前面已经提过了,这里主要说锁升级的流程)。实现方式:在当前线程的栈帧中插入lock record,赋值mark word,并尝试将mark word中的指针指向lock record,赋值成功的话,则获得轻量锁;重入时,创建一个空的lock record,解锁即弹出lock record。
自旋锁:自旋锁就是轻量级锁,当未获得轻量级锁的线程会进入自旋操作,此时就是自旋锁。
重量级锁:当线程自旋次数超过一定次数或其他线程也来竞争时轻量级锁时,就会锁膨胀为重量级锁。
参考和引用博客:
https://blog.csdn.net/javazejian/article/details/72828483
https://blog.csdn.net/qq_42914528/article/details/113777629
https://www.jianshu.com/p/5c4f441bf142
https://www.jianshu.com/p/32e1361817f0
https://segmentfault.com/a/1190000038147616
https://www.cnblogs.com/zhengbin/p/6490953.html
https://blog.csdn.net/tongdanping/article/details/79647337
https://www.jianshu.com/p/d61f294ac1a6