剖析synchronized、volatile的实现细节
线程
-
什么是线程?
线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务
-
什么是并发?
当多个线程同时执行相同的控制流时,我们就称之为并发;用代码语言来说,就是多个线程同时使用某个类,调用某个方法,修改某个变量。
-
java中创建线程的几种方式
public class Test { public static void main(String[] args) throws Exception { // 第一种,实例化一个继承自Thread的类,调用start方法 new MyThread().start(); // 第二种,实例化一个Thread并传递一个Runnable对象,调用start方法 new Thread(new MyRunnable()).start(); // 第三种,实例化一个Thread,传递一个FutureTask对象,FutureTask对象中传递Callable,调用start方法 // Callable的结果会在未来某个时间返回给FutureTask // 可以通过FutureTask的get方法获取到 FutureTask<String> futureTask = new FutureTask<>(new MyCallable()); new Thread(futureTask).start(); Thread.sleep(10); System.out.println(futureTask.get()); // 第四种,基于lambda的匿名内部类 // lambda表达式,java8才出现 new Thread(() -> { String va = "lambda..."; System.out.println(va); }).start(); // 第五种,基于线程池的方式,但是其线程的本质与上面的几种没有实质性的区别 ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> { System.out.println("thread pool..."); }); executorService.shutdownNow(); } } //以下是上面测试方法相关的测试对象 public class MyThread extends Thread { @Override public void run() { System.out.println("my thread..."); } } public class MyRunnable implements Runnable { @Override public void run() { System.out.println("my runnable..."); } } public class MyCallable implements Callable<String> { @Override public String call() throws Exception { System.out.println("my callable..."); return "callable return..."; } }
-
什么是线程安全?
当系统出现高并发,多个线程执行某个方法,或者修改某个变量的时候,如果不考虑并发问题,可能因为执行的时序从而导致各个线程间的值交叉错乱;如下单例的示例
public class SingleObj { private static SingleObj singleObj = null; private SingleObj(){} public static SingleObj getInstance(){ // 假如有10个线程同时执行到这个位置 // 那么这10个线程的 if(null == singleObj) 判断的都会是ture // 因此这10个线程就都会走if内的实例化 // 从而导致,系统中singleObj就不是单例对象了 if(null == singleObj){ singleObj = new SingleObj(); } return singleObj; } }
-
线程安全产生的原因
- 存在共享数据
- 多线程同时操作共享数据
- 缓存数据
-
解决线程安全,线程同步的手段
-
编码习惯
避免不必要的共享数据,保证堆上的所有数据不会逃逸出去从而被其他的线程访问
-
加锁;如:synchronized
将一个对象锁住,保证同一时间只有拿到锁的线程在执行
-
ThreadLocal
线程局部变量,以空间换时间,各线程将变量保存在私有的内存中
-
volatile
保证数据的可见性,防止指令重排
-
final
数据只读,不能写
-
对象的内存布局
在整理线程安全相关东西之前,我们来了解一下,一个对象在HotSpot虚拟机中的内存布局;
-
对象组成部分
- 对象头(Mark Word)
- 类型指针(Class Pointer) 这部分数据是否存在取决去虚拟机的实现
- 实例数据 (Instance Data)
- 对齐填充(Padding)
对象头
HotSpot对象头主要用于存储 运行时数据
(HashCode [ identity ],GC分代年龄,锁状态标记、偏向线程ID,偏向时间戳等),这部分数据随着当前对象的锁状态,不断的在发生变化,如下图所示;在32位和64位的虚拟机中,这部分数据分别为32bit和64bit
类型指针
指向这个类元数据的指针,比如,这个对象是一个Object,那么这个指针就指向Object,虚拟机通过这个指针来确定这个对象是那个类的实例;不过并不是所有的虚拟机实现都必须在对象数据上保留类型指针,因此,查询对象的元数据并不一定要经过对象本身,所以,对象的访问取决于虚拟机的实现,可以是通过 句柄
的方式,也可以是通过 直接指针
的方式;
- 句柄
如果使用句柄的话,那么java堆中会划分一部分内存出来用于做句柄池,reference中存储的就是对象的句柄地址,句柄地址包含了实例数据和类型数据的具体地址信息。如下图: - 直接指针
如果使用直接指针,那么在java堆对象的布局中就必须放置类型数据的相关信息,而reference中存储的就是直接的对象地址 - 优缺点对比
句柄方式多一次寻址的开销,因此,在对象的访问速度上,没有直接指针快;但是在GC导致对象发生移动的时候,句柄的方式只需要修改句柄中实例数据的指针,而reference是不需要做任何调整的。
实例数据
真正存储有效信息的区域,也就是程序代码中所定义的各种类型的字段内容。无论是从父类中继承的,还是子类中定义的,都会在这里记录起来。这部分的数据的顺序受虚拟机参数配置和字段在java代码中定义的顺序影响。HotSpot的分配策略为:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers 普通对象指针),可以看出,相同宽度被分配在一起。在满足这个前提下,父类定义的变量会出现在子类之前。如果CompactFlashs参数值为true(默认为true),那么子类较窄的变量也可能会插入到父类变量的空隙之中
对齐填充
这部分数据并不是必然存在的,同时这部分数据也没有什么实质性的含义,仅仅起到了占位符的作用;是因为HotSpot虚拟机要求对象必须是8的整数倍;因此,如果不够的情况下,需要进行填充补齐。
了解了对象在HotSpot虚拟机中的的一个基本结构之后,便于下面去分析一个对象在锁的时候,数据发生了一些说明样的变化
synchronized
-
示例代码
public class SynchronizedDemo { private Object object = new Object(); public void m(){ synchronized (object){ System.out.println("123"); } } }
基本概念:synchronized用于去锁一个对象,保证同一时间只有一个线程会拿到并持有锁,拿到锁的线程允许执行synchronized包装的代码块;记住synchronized锁的是对象,并不是锁的代码块;如上面的代码所示,锁的是object对象,执行的是synchronized后面{}内的代码,以上示例
System.out.println("123");
永远只会有一个线程调用它。 -
字节码信息
下图左侧是带synchronized的字节码,右侧是不带的字节码
上图可以看出,左侧核心的区别是多了monitorenter和monitorexit两个关键字,在这段代码直接的数据,就只能有一个线程同时访问。字节码信息查看可以下载一个插件jclasslib Bytecode viewer
- 锁的演变过程
// 再次那这个图拿出来,通过这个图,来分析一下整个对象锁的演变过程
// 下图为整个锁状态变化的过程
-
无锁态;
当一个对象刚刚创建(new Object())出来的时候,这个对象是一个崭新的,因此他属于无锁状态;此时对象头部分保存的是对象的hashcode等信息
-
偏向锁
- 当一个无锁态的对象由第一个线程来使用(糟蹋)它的时候;
- 虚拟机将对象头中的锁标识更新为
"01”
- 虚拟机通过CAS操作将线程ID记录到对象的Mark Word中;
- 持有偏向锁的线程以后每次进入这个锁相关的代码块,虚拟机都不需要进行同步操作(例如Locking、Unlocking及对Mark Word的update等);正是因为其偏袒着这个的线程,所以称其为偏向锁
- 当有另外一个线程去尝试获取这个锁的时候,偏向模式宣布结束;根据当前对象是否处于锁的状态,撤销偏向(Revoke Bias)后恢复到未锁定(“01”)状态或轻量级锁状态
-
轻量级锁(自旋锁)
- 当此时有多个线程同时获取偏向锁会升级为轻量级锁;
- 当同步对象的锁状态为01(未锁定)的时候,虚拟机首先会在当前的栈中创建一个为
锁记录(Lock Record)
的空间,用来存储当前对象Mark Word的拷贝,同时保存当前锁记录的拥有者(owner)是谁; - 然后,操作系统通过
CAS修改
对象的Mark Word数据,将锁记录(Lock Record)的指针记录到Mark Work(while(true){} 这么个意思)
-
重量级锁
- 当对象的自旋次数超过了10次(可以通过-XX:PreBlockSpin修改),或者CPU内核的1/2;此时锁就会惊动圣上(OS),升级为重量级锁
- java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成。
- 当切换到重量级锁的时候,就需要从用户态切换到内核态,线程会被挂起,因此状态转换需要消耗很多的处理时间,这个就是其重的原因
- 重量级锁的思考;
既然有轻量级锁,为什么还要存在重量级锁呢?
原因主要是因为轻量级锁是通过自旋来实现的,当出现大量的锁竞争的时候,无任何意义的自旋操作会大量占用CPU,从而导致性能的下降。
-
其他锁相关的概念
-
锁消除
锁消除是指虚拟机编译器在运行时,对一些代码上需要同步,但是被检测到不可能存在共享数据的竞争进行消除。锁消除的主要依据是来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据不会逃逸出去从而被其他的线程访问,那就可以把他们当做是栈上面的数据,从而认为你他们是线程私有的,同步加锁自然就没有说明意义;可能会想,这个过程,我们只需要在写代码的时候注意一下就好了,但是很多时候,同步的代码并不是全部由程序员控制的,比如下面的代码:public void strAppand(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1).append(s2); }
这段代码意思很简单,而且也很常用;开发过程中也经常会出现字符串拼接的操作,如:s1+s2,最终虚拟机对String拼接会按以上的方式进行优化,但是我们知道StringBuffer是线程安全的,因此他的append方法是有synchronized修饰的,那么sb的append操作难道都对StringBuffer加锁了吗?其实不然,通过上面的代码我们发现,append的作用域都限制在strAppand()的方法内部,那么方法内的所有引用永远不会“逃逸”出strAppand()内部;这样一来,虽然说sb的每个操作都加了锁,但是可以被安全的消除,在即时编译的之后,这段代码会忽略掉所有的同步直接执行。
-
锁细化
在编写代码的时候,我们要尽量保证同步代码块的作用域小,只有在有共享数据的实际作用域去进行同步,这样使得需要同步的操作数量尽量减小,如果存在竞争,那么等待锁的线程也能尽快拿到锁,下面以双重检查代理为例:public class SingleObj { private volatile static SingleObj singleObj = null; private SingleObj(){} public static SingleObj getInstance(){ if(null == singleObj){ synchronized (SingleObj.class){ if(null == singleObj){ singleObj = new SingleObj(); } } } return singleObj; } }
我们共享的数据只有singleObj这一个对象,因此,我们只需要将同步代码块放在singleObj是否等于空的校验上,这样,一旦这个单例对象创建成功之后,同步代码块就不会再执行,因此就可以提高整个代码的执行效率及性能;下面是简单粗暴的方式:
public class SingleObj { private volatile static SingleObj singleObj = null; private SingleObj(){} public synchronized static SingleObj getInstance(){ if(null == singleObj){ singleObj = new SingleObj(); } return singleObj; } }
同步代码块直接加载方法上,虽然也能确保线程安全,但是,这样的实现导致就算单例对象以及被实例化,每次通过getInstance()方法获取对象的时候,都会走加锁获取,代码性能会大大的下降。
-
锁粗化
这个和上面的锁细化是一个反的概念,既然上面说了锁细化的概念,也提倡使用锁细化,但是这里为什么又搬出来一个锁粗化的概念,因为凡事都有两面性,我们要根据实际的情况去做相应的调整,如下代码:public class NumOpe { private static int num = 0; public static void m() { for (int i = 0 ; i < 10 ; i++){ synchronized (NumOpe.class){ num++; } } } }
当我们对num进行循环追加的,希望能够保证num的线程安全,不会因为并发导致加错,如果按以上的方式加锁的,每一次循环都会有一个加锁及释放的过程;但是我们的目的是每次for循环追加10个数,因此,我们将锁同步的范围扩大(粗化)到整个操作序列的外部,完全可以对同步代码块进行以下的方式粗化:
public static void m() { synchronized (NumOpe.class){ for (int i = 0 ; i < 10 ; i++){ num++; } } }
这样既可以线程安全,同时也不会因为粒度太细而导致性能的下降;类似于StringBuffer的appand方法亦是如此。
-
自适应自旋锁
在JDK1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前-次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。
-
-
总结
- synchronized在字节码层面,表现为monitorenter和monitorexit
- synchronized在JVM层面表现为锁及锁升级的过程
- synchronized在操作系统和CPU层面使用lock comxchg(intel的实现)
- Java使用字节码和汇编语言同步分析volatile,synchronized的底层实现
volatile
- volatile解决了什么问题?
- 可见性
- 防止指令重排
可见性的问题
-
案例分析
public class VolatileTest { public static void main(String[] args) throws Exception { Mythread mythread = new Mythread(); mythread.start(); Thread.sleep(100); mythread.stopMe(); } } class Mythread extends Thread { private boolean label = false; public void stopMe() { label = true; } @Override public void run() { System.out.println("start"); while (!label) { } System.out.println("stop"); } }
如上的演示代码,当main方法跑起来之后,是否能够正常退出?答案是只会打印start就陷入到了while的死循环,永远不会退出。但是按代码逻辑来看,线程启动100ms之后就调用了stopMe(),为什么这个停止的没有生效呢?在了解原因之前,我们得先了解一下关于缓存方面的一些知识。
-
硬件的高速缓存
基于线程,我们可以在同一个计算机上面去执行多个任务;这样的目的是为了尽可能多的去“压榨”计算机,让其尽可能多的发挥其作用,但是并不是所有的任务都只是去计算;更多的任务是处理器计算,保存到内存,然后进行反复的IO操作得到一个协同的最终结果;由于处理器相比于内存运算速度相差了几个数量级,为了解决这个大的差异带来的性能问题,在处理器和内存之间加一个高速内存,这个高速内存的目的是用户处理器和内存之间的缓冲;将运算要使用到的数据拷贝到高速缓存中,让运算能够快速进行,当运算结束之后,将缓存中的数据同步到内存中,很好的解决了处理器与内存之间的速度矛盾。
-
java的工作内存
java的内存模型规定了所有的变量都保存在主内存中,但当前线程创建之后,每条线程有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存拷贝,线程中对变量的所有操作(读取、赋值等)都必须在工作线程完成,而不能直接操作主内存;不同线程之间不能相互访问工作内存中的变量,各个工作内存之间需要交互数据的话只能通过主内存。
-
带来的问题
上面的缓存与工作内存确实给机器的性能,数据的隔离带来了很大的帮助,但是却带来了一个新的问题:数据的可见性;当一个由多个线程共同维护的变量,由于缓存的存在导致各个线程之间的修改并不透明,无法在第一时间得到通知。因此就出现了上面演示代码中的问题,修改并没有对其他线程可见。 -
如何解决可见性问题:volatile
volatile修饰的变量在各个内存之间都是立即可见的;所有线程的写操作都会第一时间反应在其他线程中,关于volatile变量的可见性; -
volatile解决可见性,但是没办法保证一致性
经常会被开发人员误解,认为以下描述成立:“volatile 变量对所有线程是立即可见的,对voltile变量所有的写操作都能立刻反应到其他线程之中,换句话说,volatile 变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是其论据并不能得出“基于volatile变量的运算在并发下是安全的”这个结论。volatile 变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一-致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的,我们可以通过一段简单的代码来说明原因:public class VolatileTest2 { public static volatile int num = 0; public static void incr() { num++; } private static final int THREAD_NUM = 20; public static void main(String[] args) { // 开启20个线程,分别调用incr()对num进行追加 Thread[] threads = new Thread[THREAD_NUM]; for (int i = 0 ; i < THREAD_NUM ; i++) { threads[i] = new Thread(() -> { for (int j = 0 ; j < 10000 ; j++) { incr(); } }); threads[i].start(); } while (Thread.activeCount() > 2) Thread.yield(); System.out.println(num); } }
理论上20个线程,每个调用10000次,最后追加的结果应该是20万,由上面可以看出,几乎很小概率能够正确,那么原因出现在哪里?
// 查看这个方法的字节码 public static void incr() { num++; } // 以下为字节码 0 getstatic #2 <VolatileTest2.num> 3 iconst_1 4 iadd 5 putstatic #2 <VolatileTest2.num> 8 return //上面的字节码可以看出 num++行代码被编译成了4条指令 //getstatic指令将num的值取到栈顶的时候,volatile能保证拿到的值是最新的 //但是在执行iconst_1、iadd这两条指令的时候,没有办法保证其他的线程不做修改
那么可以通过synchronized解决这个问题
// 如果为了保证原子性使用synchronized,就可以不用volatile public synchronized static void incr() { num++; }
指令重排
-
啥是指令重排?
普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,但是没有办法保证变量赋值操作的顺序(代码顺序)和程序代码的执行顺序一致。这是JVM对代码执行的优化,更快的执行 -
指令重排测试代码
public class Disorder { static int a = 0, b = 0; static int x = 0, y = 0; public static void main(String[] args) throws InterruptedException { int i = 0; for ( ; ; ) { a = b = x = y = 0; i++; Thread thread1 = new Thread(() -> { a = 1; x = b; }); Thread thread2 = new Thread(() -> { b = 1; y = a; }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); if (x == 0 && y == 0) { System.err.println("第" + i + "次异常,X=" + x + " Y=" + y); break; } else { System.out.println("第" + i + "次,X=" + x + " Y=" + y); } } } } // 上面的代码可以分析得出x和y值可能出现以下情况 // 第一种: x=1,y=0 // 第二种: x=1,y=1 // 第三种: x=0,y=1 // 如果出现x=0 , y=0的时候,说明发生了指令重排 // 以下的测试结果证明了确实发生了重排
-
另外一个问题场景;DCL(Double Check Lock)单例是否需要加volatile?
// 我们再次回到双重检查的单例 public class SingleObj { // 这里是否需要加volatile? private volatile static SingleObj singleObj = null; private SingleObj(){} public synchronized static SingleObj getInstance(){ if(null == singleObj){ singleObj = new SingleObj(); } return singleObj; } }
-
一个简单类T的实例化过程来分析
public class T { private int i; public T() { i = 1; } public static void main(String[] args) { T t = new T(); } } //以下是T t = new T();实例化过程的字节码指令 // 实例化一个T对象 赋初始值 i=0 0 new #3 <T> 3 dup // 初始化变量 i=1 4 invokespecial #4 <T.<init>> // 将示例对象映射到t 7 astore_1 8 return // 第一步: 实例化对象T,并将变量付一个初值 // 第二步:初始化变量的值 // 第三步:实例化对象与栈里的引用t之间的建立关联,此时t就不为null
-
模拟一个指令重排的过程
// 如果现在的字节码指令的第二步和第三步发送了重排,执行顺序如下: 0 new #3 <T> 3 dup 7 astore_1 // 假如说单例的第一个线程执行到了这里,另外的并发线程进入了 // 此时堆栈中的t对象以及不为null了,按我们上面的单例方式,就直接返回了 // 但是,这会儿这个对象的值并没有对熟悉进行初始化,对象中i的i值为0 4 invokespecial #4 <T.<init>> 8 return
以上DCL指令重排的问题实在是没办法通过测试代码测试复现出来;这种情况确实出现的概率非常的低,也只有在高并发很大的情况下,极低的可能性出现,而且出现之后也非常的难追踪;概率低不代表不会出现,因此,我们在写DCL单例的时候一定要注意这个问题。
-
volatile到底经历了什么防止了指令重排?
-
测试代码
public class T { private volatile int num; private int num2; public int incr(int a, int b) { int temp = a + b; num += temp; return temp; } public int incr2(int a) { num2 += a; return num2; } public int getNum() { return num; } public static void main(String[] args) { T t = new T(); int sum = 0; int sum2 = 0; for (int i = 0 ; i < 1000 ; i++) { sum = t.incr(sum, 1); sum2 = t.incr2(i); } System.out.println("num:" + t.getNum()); System.out.println("sum:" + sum); System.out.println("sum2:" + sum2); } }
-
volatile在字节码中的体现
// 带volatile的在字节码层仅仅表现为访问标识不同
-
内存屏障(Memory Fence或者Memory Barrier)
volatile在字节码层面仅仅表现为一个访问标识的不同,在JVM和操作系统层面,表现为内存屏障,将volatile修饰的变量与其他的操作通过屏障隔离起来,不允许执行顺序发生变化;同时将值的修改立刻对其他线程可见。-
JVM中的内存屏障
-
StoreStoreBarrier和StoreLoadBarrier
写操作时;前面一个写屏障,后面一个读屏障;通俗的意思就是说,volatile写操作的时候,他之前的操作,你先写,我等着,后面的读操作,不好意思,等我先写完你再读
- StoreStore屏障
对于这样的语句Store1; StoreStore Store2, 在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 - StoreLoad屏障
对于这样的语句Store1; StoreLoad Load2,在Load2及后续所有读取操作执行前,保证Store1的写 入对所有处理器可见。
- StoreStore屏障
-
LoadLoadBarrier和LoadStoreBarrier
读操作时;前面一个读屏障,后面一个写屏障;通俗的意思就是说,volatile读操作的时候,他之前的读操作,你先读,我等你读完,后面的写操作,不好意思,等我先读完你再写
- LoadLoad屏障
对于这样的语句Load1;LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证load要读取的数据被读取完毕 - LoadStore屏障
对于这样的语句Load1; LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- LoadLoad屏障
-
-
volatile操作系统(windows)层面的实现
通过上面的代码,可以看出,加了volatile和不加在JVM中体现为一个lock addl
指令,加了内存屏障多执行的一条lock addl $0x0,(%rsp)
指令;这个操作类似于一堵墙,在重排序的时候,后面的指令不能重排序到内存屏障之前;当只有一个CPU访问内存的时候,是不需要内存屏障的,但是当多个CPU同时访问同一块内存,且其中一个在观测另外一个,就需要保证一致性;其中addl $0x0,(%rsp)
把rsp寄存器中的值增加0,很显然这个是一个空操作,关键的lock
前缀会使本CPU的Cache雪茹到主存,该写入当做也会引起其他CPU或者内核无效化(Invalidate)其Cache,这个操作相当于对Cache中的变量做了一次Java内存模型中的“store”和“write”操作。所以通过这样的一个空操作,可以让volatile变量的修改对其他CPU可见 -
volatile操作系统(linux x86 CPU)层面的实现
- sfence:在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
- lfenice: 在Ifence指令前的读操作当必须在Ifence指令后的读操作前完成。
- mfence:在mfence指 令前的读写操作当必须在mfence指令后的读写操作前完成。
-
-
总结
- synchronized
通过锁定对象的方式,保证同一时间只有一个对象对指定代码块进行访问 - volatile
通过内存屏障的方式,防止指令的重排序;所有的修改都立即同步主存,同时将其他缓存中的数据实现,保证数据的第一时间的可见性。
标题:剖析synchronized、volatile的实现细节
作者:码霸霸
地址:https://blog.lupf.cn/articles/2019/12/27/1577441498193.html