synchronized在java中的作用是线程同步,其目的是保障同步区代码的正确执行,同一时间仅有一个线程进入同步区,那他的使用方式你了解的是否全面,他的底层原理你是否清楚呢?下面就从使用方式、实例、单例和原理四个方面对synchronized进行分析:
- 三种使用方式
- 实例讲解
- 单例中的使用
- 原理浅析
请您站稳扶好,开车了...
三种使用方式
分别是修饰实例方法,修饰静态方法,修饰代码块,代码块的修饰又分为三种方式
分类 | 被锁的对象 | 示例 |
---|---|---|
实例方法 | 类的实例对象 | public synchronized void method() {} |
静态方法 | 类对象 | public static synchronized void method() {} |
实例对象 | 类的实例对象 | synchronized(this) {} |
任意Object对象 | 实例对象Object | String lock = new String(); synchronized(lock) {} |
class对象 | 类对象 | synchronized(DemoClass.class) {} |
实例讲解
下面分别通过几个实例来加深这几种使用方式的理解:
(1)实例方法
(2)静态方法
(3)this锁代码块
(4)任意锁代码块
(5)类锁代码块
(6)this代码块锁和方法
(7)任意代码块锁和方法
所有实例都是通过Runnable来建立两个线程,通过延时来查看多个线程是否同步执行
1. 实例方法
直接在方法前面加synchronized即可,其中normalMethod中进行两个线程的创建和运行
private void normalMethod() {
final ObjectMethod service = new ObjectMethod();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
service.setUserNamePassWord();
}
});
a.setName("A");
a.start();
Thread b = new Thread(new Runnable() {
@Override
public void run() {
service.setUserNamePassWord();
}
});
b.setName("B");
b.start();
}
public class ObjectMethod {
public synchronized void setUserNamePassWord(){
try {
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入普通方法:"+System.currentTimeMillis());
Thread.sleep(3000);
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入普通方法:"+System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果如下:
phototest: thread name=A 进入普通方法:1564112646128
phototest: thread name=A 进入普通方法:1564112649129
phototest: thread name=B 进入普通方法:1564112649131
phototest: thread name=B 进入普通方法:1564112652132
可以看到线程 A 和 B 依次进入和退出
结论:同一个实例情况下,修饰实例方法可以实现线程同步,因为这里我们只创建了service一个实例用两个线程来调用,如果每个线程分别传入不同的实例的话,就不能保证线程同步了
2. 静态方法
直接在静态方法前面加synchronized即可
private void staticMethod() {
final ObjectStatic serviceA = new ObjectStatic();
final ObjectStatic serviceB = new ObjectStatic();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
serviceA.setUserNamePassWord();
}
});
a.setName("A");
a.start();
Thread b = new Thread(new Runnable() {
@Override
public void run() {
serviceB.setUserNamePassWord();
}
});
b.setName("B");
b.start();
}
public static class ObjectStatic {
public synchronized static void setUserNamePassWord(){
try {
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入静态方法:"+System.currentTimeMillis());
Thread.sleep(3000);
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入静态方法:"+System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果如下:
phototest: thread name=B 进入静态方法:1564113275247
phototest: thread name=B 进入静态方法:1564113278249
phototest: thread name=A 进入静态方法:1564113278250
phototest: thread name=A 进入静态方法:1564113281252
可以看到线程 B 和 A 依次进入和退出
结论:这里两个线程分别持有是两个不同的实例,但仍然能够保证线程同步,因为对静态方法加锁实际上是对类对象加锁而不是对实例对象加锁
3. this锁代码块
在方法里面加上synchronized (this) 来修饰整个代码块
private void thisStock() {
final ThisStockObject service = new ThisStockObject();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
service.setUserNamePassWord();
}
});
a.setName("A");
a.start();
Thread b = new Thread(new Runnable() {
@Override
public void run() {
service.setUserNamePassWord();
}
});
b.setName("B");
b.start();
}
public class ThisStockObject {
public void setUserNamePassWord(){
try {
synchronized (this) {
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入this代码块:"+System.currentTimeMillis());
Thread.sleep(3000);
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入this代码块:"+System.currentTimeMillis());
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果如下:
phototest: thread name=B 进入this代码块:1564116947677
phototest: thread name=B 进入this代码块:1564116950679
phototest: thread name=A 进入this代码块:1564116950683
phototest: thread name=A 进入this代码块:1564116953684
可以看到线程 B 和 A 依次进入和退出
结论:两个线程使用同一个实例的情况下使用this代码块可保证线程同步,如果是两个实例的话就不能保证了
4. 任意锁代码块
在方法里面加上synchronized (lock) 来修饰整个代码块,其中lock是任意Object对象
private void lockStock() {
final LockStockObject service = new LockStockObject();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
service.setUserNamePassWord();
}
});
a.setName("A");
a.start();
Thread b = new Thread(new Runnable() {
@Override
public void run() {
service.setUserNamePassWord();
}
});
b.setName("B");
b.start();
}
public class LockStockObject {
private String lock=new String();
public void setUserNamePassWord(){
try {
synchronized (lock) {
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入lock代码块:"+System.currentTimeMillis());
Thread.sleep(3000);
Log.i("phototest", "thread name="+Thread.currentThread().getName()
+" 进入lock代码块:"+System.currentTimeMillis());
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果如下:
phototest: thread name=B 进入lock代码块:1564116947677
phototest: thread name=B 进入lock代码块:1564116950679
phototest: thread name=A 进入lock代码块:1564116950683
phototest: thread name=A 进入lock代码块:1564116953684
可以看到线程 B 和 A 依次进入和退出
结论:两个线程使用一个实例的情况下使用任意类型代码块锁效果和this是相同的,也是可以保证线程同步,如果是两个实例的话就不能保证了
5. 类锁代码块
在方法里面加上synchronized (DemoClass.class) 来修饰整个代码块
private void classStock() {
final ClassStockTest serviceA = new ClassStockTest();
final ClassStockTest serviceB = new ClassStockTest();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
serviceA.methodA();
}
});
a.start();
Thread b = new Thread(new Runnable() {
@Override
public void run() {
serviceB.methodB();
}
});
b.start();
}
public class ClassStockTest {
public void methodA(){
try {
synchronized (ClassStockTest.class) {
Log.i("phototest", "a begin");
Thread.sleep(3000);
Log.i("phototest", "a end");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void methodB() {
synchronized (ClassStockTest.class) {
Log.i("phototest", "b begin");
Log.i("phototest", "b end");
}
}
}
执行结果如下:
phototest: a begin
phototest: a end
phototest: b begin
phototest: b end
可以看到线程 a 和 b 依次进入和退出
结论:和静态方法加锁效果相同,也是对类对象加的锁,即使使用多个实例的情况也是可保证线程安全的
6. this代码块锁和方法
线程分别调用不同的两个方法
private void thisStockAndMethod() {
final ThisStockAndMethod service = new ThisStockAndMethod();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
service.methodA();
}
});
a.start();
Thread b = new Thread(new Runnable() {
@Override
public void run() {
service.methodB();
}
});
b.start();
}
public class ThisStockAndMethod {
public void methodA(){
try {
synchronized (this) {
Log.i("phototest", "a begin");
Thread.sleep(3000);
Log.i("phototest", "a end");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void methodB() {
Log.i("phototest", "b begin");
Log.i("phototest", "b end");
}
}
执行结果如下:
phototest: a begin
phototest: a end
phototest: b begin
phototest: b end
可以看到线程 a 和 b 依次进入和退出
结论:也是线程同步的,因为this静态代码块和methodB用的是一个锁实例,如果是不同的实例就不同步的,可以看下边lock
7. 任意代码块锁和方法
两个线程分别调用不同的两个方法
private void lockStockAndMethod() {
final LockStockAndMethod service = new LockStockAndMethod();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
service.methodA();
}
});
Thread b = new Thread(new Runnable() {
@Override
public void run() {
service.methodB();
}
});
a.start();
b.start();
}
public class LockStockAndMethod {
private String lock=new String();
public void methodA(){
try {
synchronized (lock) {
Log.i("phototest", "a begin");
Thread.sleep(3000);
Log.i("phototest", "a end");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void methodB() {
Log.i("phototest", "b begin");
Log.i("phototest", "b end");
}
}
执行结果如下:
phototest: a begin
phototest: a end
phototest: b begin
phototest: b end
可以看到线程 a 和 b 没有依次进入和退出
结论:因为methodA和methodB是使用不同的锁对象,所以不是能线程同步
单例中的使用
这里只对三种线程安全的单例模式进行分析:
首先我们直接在getInstance静态方法上synchronized,如下
class SingletonLazy1 {
private static SingletonLazy1 singletonLazy;
private SingletonLazy1() {
}
public static synchronized SingletonLazy1 getInstance() {
try {
if (null == singletonLazy) {
singletonLazy = new SingletonLazy1();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return singletonLazy;
}
}
这种方式是对类加锁,可以达到线程安全。但是缺点就是效率太低,是同步运行的,下个线程想要取得对象,就必须要等上一个线程释放,才可以继续执行。我们可通过如下代码来测试,通过观察打印的hashCode是否相同来看是否创建的是一个单例对象:
public class SingletonLazyTest {
public static void main(String[] args) {
Thread2[] ThreadArr = new Thread2[10];
for (int i = 0; i < ThreadArr.length; i++) {
ThreadArr[i] = new Thread2();
ThreadArr[i].start();
}
}
}
// 测试线程
class Thread2 extends Thread {
@Override
public void run() {
System.out.println(SingletonLazy1.getInstance().hashCode());
}
}
测试结果如下,打印的hashCode相同,说明是线程安全的
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
1210004989
那我们可以不对方法加锁,而是对里面的代码加锁,也可以实现线程安全。但这种方式和同步方法一样,是对类对象加的锁,效率也很低,如下:
class SingletonLazy2 {
private static SingletonLazy2 singletonLazy;
private SingletonLazy2() {
}
public static SingletonLazy2 getInstance() {
try {
synchronized (SingletonLazy2.class) {
if (null == singletonLazy) {
singletonLazy = new SingletonLazy2();
}
}
} catch (InterruptedException e) {
}
return singletonLazy;
}
}
我们来继续优化代码,我们只给创建对象的代码进行加锁,但是这样能保证线程安全么?,如下:
class SingletonLazy3 {
private static SingletonLazy3 singletonLazy;
private SingletonLazy3() {
}
public static SingletonLazy3 getInstance() {
try {
if (null == singletonLazy) { //代码1
// 模拟在创建对象之前做一些准备工作
Thread.sleep(1000);
synchronized (SingletonLazy3.class) {
singletonLazy = new SingletonLazy3(); //代码2
}
}
} catch (InterruptedException e) {
}
return singletonLazy;
}
}
测试结果如下,hashCode不一致,说明不是线程安全的
1210004989
1425839054
1723650563
389001266
1356914048
389001266
1560241484
278778395
124191239
367137303
那为啥这就不是线程安全的了呢?我们假设有两个线程A和B同时走到了‘代码1’,因为此时对象还是空的,所以都能进到方法里面,线程A首先抢到锁,创建了对象。释放锁后线程B拿到了锁也会走到‘代码2’,也创建了一个对象,因此多线程环境下就不能保证单例了。
让我们来继续优化一下,既然上述方式存在问题,那我们在同步代码块里面再一次做一下null判断不就行了,这种方式就是我们的DCL双重检查锁机制,如下:
class SingletonLazy4 {
private static SingletonLazy4 singletonLazy;
private SingletonLazy4() {
}
public static SingletonLazy4 getInstance() {
try {
if (null == singletonLazy) {
// 模拟在创建对象之前做一些准备工作
Thread.sleep(1000);
synchronized (SingletonLazy4.class) {
if(null == singletonLazy) {
singletonLazy = new SingletonLazy4();
}
}
}
} catch (InterruptedException e) {
// TODO: handle exception
}
return singletonLazy;
}
}
结果如下,打印的hashCode是相同的
124191239
124191239
124191239
124191239
124191239
124191239
124191239
124191239
124191239
124191239
这也是我们常用的机制,虽然写法复杂但是效率高而且是线程安全
原理浅析
对于synchronized这个关键字,可能之前大家有听过,他是一个重量级锁,开销很大,建议大家少用点。但大家可能也听说过,但到了jdk1.6之后,该关键字被进行了很多的优化,已经不像以前那样不给力了,建议大家多使用。
1. 任意对象皆可为锁
Java虚拟机中的同步基于进入和退出管程(Monitor)对象实现,无论是显式同步(有明确的monitorenter和monitorexit指令,即代码块同步)还是隐式同步都是如此。在Java语言中,同步用的最多的地方可能是被synchronized修饰的同步方法,同步方法并不是由monitorenter和monitorexit指令来实现的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来隐式显示的。
下面先来了解一下Java对象头的概念,这对深入理解synchronized实现原理非常关键。在java中,任何一个对象都能成为锁对象。为了让大家更好着理解虚拟机是如何知道这个对象就是一个锁对象的,我们下面简单介绍一下java中一个对象的结构,java对象在内存中的存储结构主要有一下三个部分:
(1)对象头
(2)实例数据
(3)填充数据
其中对象头中的内容如下:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Work | hashCode,GC分代年龄,锁信息 |
32/64bit | Class Metadata Address | 指向对象类型数据的指针 |
32/64bit | Array Length | 数组的长度(当对象为数组时) |
可以看到,关于锁信息的数据都是在Mark Work里面,Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也被称为管程或者监视器锁)的起始地址,每个对象都存在一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。在JVM中,monitor是由ObjectMonitor实现的,其结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_Wait和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList集合,当线程获取到对象的monitor后进入_owner区域并把monitor中的owner变量设置成当前线程,同时monitor中的计数器加1,如果线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count 自减1,同时该线程进入waitSet等待唤醒。如果当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程进入获取monitor。
接下来思考一个问题,wait()、notify()和notifyAll()方法为什么属于Object,而不是放到Thread类中呢?
原因一:Java中,任何对象都可以作为锁,既然wait是放弃对象锁,当然就要把wait定义在这个对象所属的类中。更通用一些,由于所有类都继承于Object,我们完全可以把wait方法定义在Object类中,这样,当我们定义一个新类,并需要以它的一个对象作为锁时,不需要我们再重新定义wait方法的实现,而是直接调用父类的wait(也就是Object的wait),此处,用到了Java的继承。
原因二:有的人会说,既然是线程放弃对象锁,那也可以把wait定义在Thread类里面啊,新定义的线程继承于Thread类,也不需要重新定义wait方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂。
2. 两种锁当原理
方法锁原理:
方法同步是隐式的,即无需通过字节码指令来控制,JVM可以从method_info Structure(方法表结构)中的ACC_SYNCHRONIZED访问标志区分一个方法是否为同步方法,当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有monitor,然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
代码块锁原理:
同步代码块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指向代码块结尾和异常处。当执行monitorenter指令时,当前线程将尝试获得对应的monitor的持有权,如果monitor的进入计数器为0,那么线程可以成功获得monitor,并且将计数器设置为1,取锁成功。如果当前线程已经拥有了对应monitor的所有权,那它可以重入这个monitor(重入性),重入时计数器的值也会加1。如果其他线程已经拥有了这个monitor的所有权,那当前线程会被阻塞,直到正在执行的线程执行完毕,即monitorexit指令执行,执行线程将释放monitor并将计数器重置为0。
3. 几种锁的概念
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁,随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是锁的升级是单向的,只能从低到高升级,不会出现锁的降级,重量级锁synchronized已经分析过,下面将介绍偏向锁和轻量级锁以及JVM的其他优化手段。
偏向锁:
偏向锁是Java6之后加入的新锁,它是一种针对加锁操作的优化手段,在只有一个线程执行同步代码块的时候,为了减少同一线程获取锁的代价(会涉及到一下CAS操作,耗时)而引入偏向锁。偏向锁的核心思想是如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能,所以对于没有锁竞争的场合,偏向锁有很好的优化效果。但是对于锁竞争比较激烈的场合,偏向锁就失效了,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁:
如果偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word的结构也变成了轻量级锁的结构。轻量级锁适用的场景是所有线程交替执行同步代码块的场合,如果存在同一时间访问同一所的场合,就会导致轻量级所膨胀为重量级锁。轻量级锁主要有两种,自旋锁和自适应自旋锁。
轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待,串行执行。
自旋锁:
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景。
自适应自旋锁:
所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。
锁消除:
锁消除是虚拟机另外一种对锁的优化,这种优化更彻底,Java虚拟机在即时编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁, 通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
CAS:
独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁用到的机制就是CAS,Compare and Swap。
CAS全称Compare And Set(或Compare And Swap),CAS包含三个操作数:内存位置(V)、原值(A)、新值(B)。简单来说CAS操作就是一个虚拟机实现的原子操作,这个原子操作的功能就是将旧值(A)替换为新值(B),如果旧值(A)未被改变,则替换成功,如果旧值(A)已经被改变则替换失败。
可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自已已持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程得到一个对象锁后再次请求该对象锁,是允许的,monitor中的计数器也会照常加1,这就是synchronized的可重入性。
等待和唤醒
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。
需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。
尊重作者,尊重原创,参考文章:
使用实例:https://blog.csdn.net/luckey_zh/article/details/53815694
单例:这链接太长了
原理相关:https://blog.csdn.net/qq_38462278/article/details/81976428
https://www.jianshu.com/p/d53bf830fa09
https://blog.csdn.net/m0_37698652/article/details/88119434
https://blog.csdn.net/liuzhixiong_521/article/details/86666787
网友评论