Synchronized关键字

在Java中除了提供Lock API外还在语法层面上提供了synchronized关键字来实现互斥同步原语,可以保证同步对象的原子性、可见性和有序性( 加上synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码快中的代码,从而保证有序性)。

不过需要注意的是,这里保证可见性和volatile保证可见性有一定区别。volatile保证绝对的可见性,synchronized是通过获取锁时强制从主存取数据保证的,如果访问变量时没有获取锁,就不保证可见性。

1 Synchronized的使用

synchronized可修饰的对象有以下几种:

  1. 方法

    被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象 。

  2. 静态方法

    其作用的范围是整个静态方法,作用的对象是这个类的所有对象。

  3. 代码块

    被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象。

  4. 其作用的范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象。

    1
    2
    3
    4
    5
    6
    7
    class ClassName {
    public void method() {
    synchronized(ClassName.class) {
    // todo
    }
    }
    }

另外需要注意的一点是: synchronized具有锁重入功能,也就是说一个线程获得锁,再次请求是可以再次得到对象的锁的 。关于这部分的具体实验,可参考这篇博客

2 Synchronized原理

2.1 加/释放锁原理

1
2
3
4
5
6
7
8
9
10
11
12
public class SynchronizedDemo2 {
Object object = new Object();
public void method1() {
synchronized (object) {

}
method2();
}
private static void method2() {

}
}

将上述代码用javac编译生成.class文件后,使用javap反编译查看.class文件信息如下:

可以看出synchronized的底层原理在于monitorentermonitorexit两条指令。 MonitorenterMonitorexit指令可以让被执行对象的锁计数器加1或者减1。 每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待

  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加

  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是将monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

2.2 可重入原理

上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。

Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加1,释放锁后就会将计数器减1。

2.3 可见性原理

synchronized规定,线程在加锁时,先清空工作内存→在主内存中拷贝最新变量的副本到工作内存→执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。

3 锁升级

3.1 为什么要锁升级

通过前面的介绍,我们知道Synchronized依赖于monitor,而monitor依赖于底层操作系统的mutex lock来实现,因此需要从用户态切换到内核态,而这一操作是重量级的。

在JDK1.5之前,synchronized是重量级锁,1.6以后对其进行了优化,有了一个 无锁–>偏向锁–>自旋锁–>重量级锁 的锁升级的过程,而不是一上来就是重量级锁了,为什么呢?因为重量级锁获取锁和释放锁需要经过操作系统,是一个重量级的操作。对于重量锁来说,一旦线程获取失败,就要陷入阻塞状态,并且是操作系统层面的阻塞,这个过程涉及用户态到核心态的切换,是一个开销非常大的操作。而研究表明,线程持有锁的时间是比较短暂的,也就是说,当前线程即使现在获取锁失败,但可能很快地将来就能够获取到锁,这种情况下将线程挂起是很不划算的行为。所以要对"synchronized总是启用重量级锁"这个机制进行优化。

3.2 锁升级原理

在Java虚拟机中,普通对象在内存中分为三块区域:对象头、实例数据、对齐填充数据(数组对象比普通对象在对象头位置多一个数组长度)。

  • 对象头:包括markword(8字节)和类型指针(开启压缩指针4字节,不开启8字节,如果是32g以上内存,都是8字节)
  • 实例数据:就是对象的成员变量
  • padding:就是为了保证对象的大小为8字节的倍数,将对象所占字节数补到能被8整除。

和锁相关的信息就存储在对象头中。

64位HotSpot JVM中,如下图所示,不同锁状态下,对象头存储不同的信息:

目前锁状态一种有四种,从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁,锁状态只能升级,不能降级。

下图总结了锁状态变化路线图:

可以看到上图多了一个“匿名偏向锁”:

  1. 什么是匿名偏向锁?

    匿名偏向锁,就是不偏向任何线程的偏向锁。JVM中有一个启动参数-XX:BiasedLockingStartupDelay,表示延时启动偏向锁的时间。即如果在延时时间内创建的对象,会进入无锁状态,延时时间外创建的对象处于匿名偏向锁状态。

  2. 为什么要有它?

    JVM 内部的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略 ,这样启动后立刻创建的对象处于无锁状态,产生竞争后,直接进入轻量级锁,不使用偏向锁。

3.3 无锁状态

无锁状态,标志位为 0 01,此时对象没有任何同步限制。

3.4 偏向锁

偏向锁状态,标志位为1 01。

3.4.1 为什么要有偏向锁

有研究表明,其实在大部分场景都不会发生锁资源竞争,并且锁资源往往都是由一个线程获得的。如果这种情况下,同一个线程获取这个锁都需要进行一系列操作,比如说CAS自旋,那这个操作很明显是多余的。偏向锁就解决了这个问题。其核心思想就是:一个线程获取到了锁,那么锁就会进入偏向模式,当同一个线程再次请求该锁的时候,无需做任何同步,直接进行同步区域执行。这样就省去了大量有关锁申请的操作。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。

3.4.2 偏向撤销

在了解偏向锁之前,需要先了解下偏向撤销的概念。可以参考这个

偏向锁撤销和偏向锁释放是两码事

  1. 撤销:笼统的说就是多个线程竞争导致不能再使用偏向模式的时候,主要是告知这个锁对象不能再用偏向模式
  2. 释放:和你的常规理解一样,对应的就是 synchronized 方法的退出或 synchronized 块的结束

如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下。

想要撤销偏向锁,还不能对持有偏向锁的线程有影响,所以就要等待持有偏向锁的线程到达一个 safepoint 安全点 (这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程。

在这个安全点,线程可能还是处在不同状态的,先说结论(因为源码就是这么写的,可能有疑惑的地方会在后面解释)

  1. 线程不存活或者活着的线程但退出了同步块,很简单,直接撤销偏向就好了
  2. 活着的线程但仍在同步块之内,那就要升级成轻量级锁

这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。偏向锁是特定场景下提升程序效率的方案,可并不代表程序员写的程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作
  2. 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销

很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,JVM为我们提供了两重底线:批量重偏向和批量撤销,主要这两个措施是针对类的,而不是对象。

1、批量重偏向和批量撤销是针对类的优化,和对象无关,对象进入轻量级锁后,就不会再使用偏向锁了。

2、偏向锁重偏向一次之后不可再次重偏向。

3、当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利

一、批量重偏向

重偏向:重新换个线程偏向。

对于某个类,在A线程中创建了100个对象然后,并进入同步代码块,完成任务后退出。在B线程中,如果分别使用那100个对象进入临界区,因为此时这100个对象都是偏向A线程的,那么这100次操作都是直接获取轻量级锁,这会造成很多额外开销。

如果设置重偏向阈值为20,那么对比B线程前19次操作都是获取轻量级锁,第20次发生重偏向,开始使用偏向锁。

这是第一种场景的快速解决方案, 以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器 +1,当这个值达到重偏向阈值(默认20)时:

这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器 +1,当这个值达到重偏向阈值(默认20)时:

1
BiasedLockingBulkRebiasThreshold = 20

JVM 就认为该class的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 epoch

每个 class 对象会有一个对应的epoch字段,每个处于偏向锁状态对象mark word 中也有该字段,其初始值为创建该对象时 class 中的epoch的值(此时二者是相等的)。每次发生批量重偏向时,就将该值加1,同时遍历JVM中所有线程的栈

  1. 找到该 class 所有正处于加锁状态的偏向锁对象,将其epoch字段改为新值
  2. class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持 epoch 字段值不变

这样下次获得锁时,发现当前对象的epoch值和class的epoch不同,本着今朝不问前朝事 的原则(上一个纪元),那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其mark word的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;

如果 epoch 都一样,说明没有发生过批量重偏向, 如果 markword 有线程ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0)。

批量重偏向是第一阶梯底线,还有第二阶梯底线

二、 批量撤销

当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认40)时,

1
BiasedLockingBulkRevokeThreshold = 40

JVM就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑

这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还给一次改过自新的机会,那就是另外一个计时器:

1
BiasedLockingDecayTime = 25000
  1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到40,就会发生批量撤销(偏向锁彻底 game over)
  2. 如果在距离上次批量重偏向发生超过 25 秒之外,那么就会重置在 [20, 40) 内的计数, 再给次机会

大家有兴趣可以写代码测试一下临界点,观察锁对象 markword 的变化。

至此,整个偏向锁的工作流程可以用一张图表示:

下面看一个例子:

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
public static void main(String[] args) throws Exception {
//延时产生可偏向对象
Thread.sleep(5000);

//创造100个偏向线程t1的偏向锁
List<MyThread> listA = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i <100 ; i++) {
MyThread a = new MyThread();
synchronized (a){
listA.add(a);
}
}
try {
//为了防止JVM线程复用,在创建完对象后,保持线程t1状态为存活
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();

//睡眠3s钟保证线程t1创建对象完成
Thread.sleep(3000);
System.out.println("打印t1线程,list中第20个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(19)).toPrintable()));

//创建线程t2竞争线程t1中已经退出同步块的锁
Thread t2 = new Thread(() -> {
//这里面只循环了40次!!!
for (int i = 0; i < 40; i++) {
MyThread a =listA.get(i);
synchronized (a){
//分别打印第19次和第20次偏向锁重偏向结果
if(i==18||i==19){
System.out.println("第"+ ( i + 1) + "次偏向结果");
System.out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
Thread.sleep(3000);
System.out.println("打印list中第11个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(10)).toPrintable()));
System.out.println("打印list中第26个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(25)).toPrintable()));
System.out.println("打印list中第41个对象的对象头:");
System.out.println((ClassLayout.parseInstance(listA.get(40)).toPrintable()));

Thread t3 = new Thread(() -> {
for (int i = 20; i < 40; i++) {
MyThread a =listA.get(i);
synchronized (a){
if(i==20||i==22){
System.out.println("thread3 第"+ i + "次");
System.out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
});
t3.start();


Thread.sleep(10000);
System.out.println("重新输出新实例A");
System.out.println((ClassLayout.parseInstance(new MyThread()).toPrintable()));
}

总结一下就是: 一开始100个对象偏向线程1,线程2中前19次执行,直接出发轻量级锁,到第20次,达到阈值,触发重偏向,第20~40个对象,发生重偏向,重新偏向线程2。在线程3中,由于达到阈值40,因此发生批量撤销,不会再次重偏向到线程3,而是直接触发轻量级锁。

3.4.3 偏向锁适用场景

使用与不常发生锁竞争的场景。

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁。在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。所以一般JVM并不是一开始就开启偏向锁的,而是有一定的延迟,这也就是为什么会有无锁态的原因。可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁。

3.5 轻量级锁

当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,销偏向锁状态,将锁对象markWord中62位修改成指向自己线程栈中Lock Record的指针(CAS抢)执行在用户态,消耗CPU的资源(自旋锁不适合锁定时间长的场景、等待线程特别多的场景),此时锁标志位为:00。

3.5.1 自适策略

JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。

JDK 1.6引入了更加聪明的自旋锁,叫做自适应自旋锁。他的自旋次数是会变的,我用大白话来讲一下,就是线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

3.5.2 加锁过程

在代码进入同步块的时候:

  1. 如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。

  2. 拷贝对象头中的Mark Word复制到锁记录中;

  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word中的62位更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。

  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。

  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。此时为了提高获取锁的效率,线程会不断地循环去获取锁, 这个循环是有次数限制的。

    • 如果在循环结束之前CAS操作成功, 那么线程就获取到锁,

    • 如果循环结束依然获取不到锁, 则获取锁失败, 对象的MarkWord中的记录会被修改为指向互斥量(重量级锁)的指针,锁标志的状态值变为10,线程被挂起,后面来的线程也会直接被挂起。

3.5.3 释放锁

释放锁线程视角: 由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。因为重量级锁被修改了,所有display mark word和原来的markword不一样了。
怎么补救,就是进入mutex前,compare一下obj的markword状态。确认该markword是否被其他线程持有。此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。

尝试获取锁线程视角: 如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。

从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,这里我不建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。

3.6 重量级锁

此时锁标志位为:10。前面我们提到的markWord,若是重量锁,对象头中还会存在一个监视器对象,也就是Monitor对象。这个Monitor对象就是实现synchronized的一个关键。

在Java虚拟机(HotSpot)中,Monitor对象其实就是ObjectMonitor对象,这个对象是一个C++对象,定义在虚拟机源码中。

ObjectMonitor有比较多的属性,但是比较重要的属性有四个:

  • _count:计数器。用来记录获取锁的次数。该属性主要用来实现重入锁机制。
  • owner:记录着当前锁对象的持有者线程。
  • _WaitSet:队列。当一个线程调用了wait方法后,它会释放锁资源,进入WaitSet队列等待被唤醒。
  • EntryList:队列。里面存放着所有申请该锁对象的线程。

所以一个线程获取锁对象的流程如下:

  1. 判断锁对象的锁标志位是重量级锁,于是想要获取Monitor对象锁。
  2. 如果Monitor中的_ count属性是0,说明当前锁可用,于是把 _ owner 属性设置为本线程,然后把 _ count 属性+1。这就成功地完成了锁的获取。
  3. 如果Monitor中的_count属性不为0,再检查 _owner 属性,如果该属性指向了本线程,说明可以重入锁,于是把 _count 属性再加上1,实现锁的重入。
  4. 如果 _owner 属性指向了其他线程,那么该线程进入 _EntryList 队列中等待锁资源的释放。
  5. 如果线程在持有锁的过程中调用了wait()方法,那么线程释放锁对象,然后进入 _WaitSet 队列中等待被唤醒。

3.7 Hashcode哪儿去了

从前文我们知道,只有无锁状态下,对象头才会存储hashcode。在其他状态下,我们要获取hashcode值该怎么办呢?

首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过第一次调用 Object::hashCode() 或者System::identityHashCode(Object) 才会存储在对象头中的。第一次生成的 hashcode后,该值应该是一直保持不变的,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?

  1. 即便初始化为可偏向状态的对象,一旦调用 Object::hashCode() 或者System::identityHashCode(Object) ,进入同步块就会直接使用轻量级锁
  2. 已经生成hashcode,进入同步代码块,直接使用轻量级锁
  3. 如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁
  4. wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦)

轻量级锁: 获取锁后,会在栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。释放锁后再将其换回去。

重量级锁: ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值

4 锁优化

以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作。

  1. 锁消除

    锁消除用大白话来讲,就是在一段程序里你用了锁,但是jvm检测到这段程序里不存在共享数据竞争问题,也就是变量没有逃逸出方法外,这个时候jvm就会把这个锁消除掉

    我们程序员写代码的时候自然是知道哪里需要上锁,哪里不需要,但是有时候我们虽然没有显示使用锁,但是我们不小心使了一些线程安全的API时,如StringBuffer、Vector、HashTable等,这个时候会隐形的加锁。

  2. 减少锁的时间

    不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放

  3. 减小锁的粒度

    它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间(如ConcurrentHashMap、LinkedBlockingQueue、LongAdder);

  4. 锁粗化

    大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度; 在以下场景下需要粗化锁的粒度:
    假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

  5. 使用读写锁

    ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。

参考资料

  1. https://segmentfault.com/a/1190000039755370
  2. https://blog.csdn.net/weixin_40910372/article/details/107726978
  3. https://segmentfault.com/a/1190000041194920
  4. https://www.cnblogs.com/LemonFive/p/11248248.html
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2021-2022 Yin Peng
  • 引擎: Hexo   |  主题:修改自 Ayer
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信