JVM3:现代cpu模型和 JMM底层实现原理

一.现代cpu模型在多线程下的不足

1.写在前面

一段代码引来的思考:为什么程序一直走不出Thread_One的while循环呢?

public class Test{

    public static boolean threadOneFlag = true;

    public volatile static boolean threadTwoFlag = true;
    
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            System.out.println("thread_one_start");
            while (threadOneFlag){ }
            System.out.println("thread_one_end");
        },"Thread_One").start();

        new Thread(()->{
            System.out.println("thread_two_start");
            while (threadTwoFlag){ }
            System.out.println("thread_two_end");
        },"Thread_Two").start();
        Thread.sleep(1000);
        //对threadOneFlag变量的修改在线程Thread_One中并不可见
        threadOneFlag = false;
        threadTwoFlag = false;
    }
}

运行结果:
在这里插入图片描述

2.从硬件层面了解可见性的本质

程序运行时用到的存储设备有:CPU、内存、磁盘(IO设备),三者有不同的处理速度,而且差异很大。当一个程序运行时如果三者都需要访问,如果不做任何处理的话,计算效率受限于最慢的设备,计算机硬件对此做了一些优化:

  • CPU增加了高速缓存
  • 多核CPU并且增加了进程、线程概念,通过时间片切换最大化提升CPU的使用率
  • 编译器的指令优化,更合理的去利用好CPU的高速缓存
    这些优化虽然提升了计算机的计算效率,但是却带来的可见性和重排序的问题,下面慢慢讲解

3.CPU高速缓存

  • 存在的意义:绝大多数的运算任务不能仅通过处理器来完成,还需要和内存进行交互。例如:读取运算数据,存储运算结果。因为计算机的存储设备与处理器运算速度差距很大,所以会增加CPU高速缓存作为两者之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
  • 存在的弊端:会带来缓存一致性的问题
  • CPU高速缓存的结构:
    分为L1,L2,L3三级缓存,L1和L2是CPU私有的,其中L1最小,L1又分为数据缓存和指令缓存
    在这里插入图片描述
  •  

4.缓存一致性

  • 当高速缓存存在以后,每个CPU获取/存储数据直接操作高速缓存,而不是内存,这样当多个线程运行在不同CPU中时。同一份内存数据就可能会缓存于多个CPU高速缓存中,如不进行限制,就会出现缓存一致性问题
  • CPU层面提出了两种解决办法:1. 总线锁,2. 缓存锁

5.总线锁和缓存锁

  • 总线锁:在多CPU下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK信号,使得其他处理器无法访问共享数据,开销很大,如果我们能够控制锁的粒度就能减少开销,从而引入了缓存锁。
  • 缓存锁:只要保证多个CPU缓存的同一份数据是一致的就可以了,基于缓存一致性协议来实现的

6.缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI、MESI、MOSI。最常见的是MESI协议。

7.MESI协议

在MESI协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听其他Cache的读写操作。共有四种状态,分别是:

  • M(Modify)表示共享数据只缓存在当前CPU缓存中,并且是被修改的状态。此时表示当前CPU缓存数据与主内存中不一致,其他CPU缓存中如果缓存了当前数据应是无效状态,因为该数据已被修改且并没更新到主内存
  • E(Exclusive)表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  • S(Shared)表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存中的数据一致
  • I(Invalid)表示当前缓存已经失效
  • 图解四种状态:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
  • 对于MESI协议,从CPU读写角度来说会遵循一下原则:
    1. CPU读请求:缓存处于M、E、S状态都可以被读取,I状态CPU只能从主内存中读取数据
    2. CPU写请求:缓存处于M、E状态才可以被写入主内存中。对于S状态的写,需要将其他CPU中缓存行设置为无效才可写。
  • 使用总线锁和缓存锁机制之后,CPU对于内存的操作可以做如下抽象:
    在这里插入图片描述

8.MESI协议的不足之处

  • 当一个CPU_0需要将缓存中的数据进行写入时,首先需要发送失效信息给其他缓存了该数据的CPU,等回执确认之后才会进行写入。等待回执确认的过程中CPU_0会处于阻塞状态,为了避免阻塞造成的资源浪费,CPU中引入了Store Bufferes。
  • 引入Sotr Bufferes后,CPU_0在写入共享数据时,只需将数据写入store bufferes中,同时向其他缓存了共享数据的CPU发送失效指令就可以做其他操作了。由store bufferes等待回执确认信息,并负责同步到主内存
    在这里插入图片描述
  • 这种优化方式带来了两个现象,引起重排序的问题:
    1. 数据什么时候提交不确定,因为需要等待其他CPU确认回执之后才会提交,这是一个异步操作
    2. 引入storebufferes后,处理器会先尝试从storebuffere中读取值,如果storebufferes中有数据,则直接从storebuffer中读取,否则再从缓存行中读取

9.写合并

现代CPU采用了大量的技术来抵消内存访问带来的延迟。读写内存数据期间,CPU能执行成百上千条指令。

多级SRAM缓存是减小这种延迟带来的影响的主要手段。此外,SMP系统采用消息传递协议来实现缓存之间的一致性。遗憾的是,现代的CPU实在是太快了,即使是使用了缓存,

有时也无法跟上CPU  的速度。因此,为了进一步减小延迟的影响,一些鲜为人知的缓冲区派上了用场。

本文将探讨“合并写存储缓冲区(write combining store buffers)”,以及如何写出有效利用它们的代码。

10.指令重排序

  • 请看如下代码:假如exeToCPU0和exeToCPU1执行在不同CPU上,当exeToCPU0执行完两行赋值代码时,此时exeToCPU1执行if语句时,isFinsh = true,但是可能value并不为10,这就是重排序问题。
  • 原因在于:假设CPU0缓存的两个变量及状态为:isFinish(E),value(S),CPU0修改value时只会先将修改结果保存到Store Buffer中,然后继续执行isFinish=true指令,因为isFinish是(E),所以会直接将修改结果写入内存中。此时CPU1读书两个值时,可能的结果就是:isfinish=true,value=3(不等于10)
    在这里插入图片描述
  • 为了解决此类问题,CPU层面提出了内存屏障

11.CPU层面的内存屏障

  • 可以将其粗犷的理解为:将store buffer中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性
  • X86的 memory barrier的指令包括:读屏障、写屏障以及全屏障
  • 写屏障:告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,也就是,写屏障之前的指令对于屏障之后的读操作都是可见的。
  • 读屏障:处理器读屏障之后的读操作都在屏障之后执行
  • 全屏障:确保屏障前的内存读写操作的结果都对屏障之后的操作可见
  • 这些都不需要我们程序员来维护,和我们直接打交道的是JMM

由上可看出现代CPU数据一致性实现=缓存锁(MESI等各种协议)+总线锁

并不能解决多线程数据一致性的问题,所以java要自己实现自己的内存模型 Java Memory Model

二.java 内存模型JMM

1.什么是JMM

  • JMM全称是Java Memory Model,是隶属于JVM的,是属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范。JMM并没有提升或者损失执行性能,也没有直接限制指令重排序,JMM只是将底层问题抽象到JVM层面,是基于CPU层面提供的内存屏障及限制编译器的重排序来解决问题的
  • JMM抽象模型分为主内存和工作内存。主内存是所有线程共享的,工作内存是每个线程独占的。线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间共享变量的传递都是基于主内存来完成的
  • JMM体统了一些禁用缓存以及禁止重排序的方法,来解决可见性和有序性问题,例如:volatile、synchronized、final
  • 在JMM中如果一个操作的执行结果必须对另外一个操作可见,两个操作必须要存在happens-before关系,即happen-before规则(具体参见:happen-before规则)。

我们常说的JVM内存模式指的是JVM的内存分区;而Java内存模式是一种虚拟机规范。

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

原始的Java内存模型存在一些不足,因此Java内存模型在Java1.5时被重新修订。这个版本的Java内存模型在Java8中仍然在使用。

Java内存模型(不仅仅是JVM内存分区):调用栈和本地变量存放在线程栈上,对象存放在堆上。

  • 一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
  • 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
  • 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量仍然存放在线程栈上,即使这些方法所属的对象存放在堆上。
  • 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
  • 静态成员变量跟随着类定义一起也存放在堆上。
  • 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

Java内存模型和硬件内存架构之间的桥接

Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存(Main Memory)中
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。

JMM模型下的线程间通信:

线程间通信必须要经过主内存。

如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,

Java内存模型定义了以下八种操作来完成,简称8大原子操作(在最新的JSR-133中已经弃用,了解即可):

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

Java内存模型解决的问题

当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。Java内存模型建立所围绕的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性(多线程竞争race condition)。

1、多线程读同步与可见性

可见性(共享对象可见性):线程对共享变量修改的可见性。当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改

线程缓存导致的可见性问题:

如果两个或者更多的线程在没有正确的使用volatile声明或者同步的情况下共享一个对象,一个线程更新这个共享对象可能对其它线程来说是不可见的:共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中,然后修改了这个对象。只要CPU缓存没有被刷新会主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。

下图示意了这种情形。跑在左边CPU的线程拷贝这个共享对象到它的CPU缓存中,然后将count变量的值修改为2。这个修改对跑在右边CPU上的其它线程是不可见的,因为修改后的count的值还没有被刷新回主存中去。

解决这个内存可见性问题你可以使用:

  • Java中的volatile关键字:volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
  • Java中的synchronized关键字:同步快的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值”、“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这两条规则获得的。
  • Java中的final关键字:final关键字的可见性是指,被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程就能看见final字段的值(无须同步)

重排序导致的可见性问题:

Java程序中天然的有序性可以总结为一句话:如果在本地线程内观察,所有操作都是有序的(“线程内表现为串行”(Within-Thread As-If-Serial Semantics));如果在一个线程中观察另一个线程,所有操作都是无序的(“指令重排序”现象和“线程工作内存与主内存同步延迟”现象)。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性:

  • volatile关键字本身就包含了禁止指令重排序的语义
  • synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

指令序列的重排序:

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序:

数据依赖:

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。(这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑)

指令重排序对内存可见性的影响:

当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。这样的结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

指令重排序改变多线程程序的执行结果例子:

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

as-if-serial语义:

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)

happens before:

从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性:在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

一个happens-before规则对应于一个或多个编译器和处理器重排序规则

内存屏障禁止特定类型的处理器重排序:

重排序可能会导致多线程程序出现内存可见性问题。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

2、多线程写同步与原子性

多线程竞争(Race Conditions)问题:当读,写和检查共享变量时出现race conditions。

如果两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新变量,就有可能发生race conditions。

想象一下,如果线程A读一个共享对象的变量count到它的CPU缓存中。再想象一下,线程B也做了同样的事情,但是往一个不同的CPU缓存中。现在线程A将count加1,线程B也做了同样的事情。现在count已经被增加了两次,每个CPU缓存中一次。如果这些增加操作被顺序的执行,变量count应该被增加两次,然后原值+2被写回到主存中去。然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1,尽管增加了两次:

解决这个问题可以使用Java同步块。一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile。

使用原子性保证多线程写同步问题:

原子性:指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。

  • Reads and writes are atomic for reference variables and for most primitive variables (all types except long and double).
  • Reads and writes are atomic for all variables declared volatile (including long and double variables).

https://docs.oracle.com/javase/tutorial/essential/concurrency/atomic.html

实现原子性:

  • 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write,我们大致可以认为基本数据类型变量、引用类型变量、声明为volatile的任何类型变量的访问读写是具备原子性的(long和double的非原子性协定:对于64位的数据,如long和double,Java内存模型规范允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这四个操作的原子性,即如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。但由于目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此在编写代码时一般也不需要将用到的long和double变量专门声明为volatile)。这些类型变量的读、写天然具有原子性,但类似于 “基本变量++” / “volatile++” 这种复合操作并没有原子性。
  • 如果应用场景需要一个更大范围的原子性保证,需要使用同步块技术。Java内存模型提供了lock和unlock操作来满足这种需求。虚拟机提供了字节码指令monitorenter和monitorexist来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步快——synchronized关键字。

JMM对特殊Java语义的特殊规则支持

volatile总结 (保证内存可见性:Lock前缀的指令、内存屏障禁止重排序)

synchronized总结 (保证内存可见性和操作原子性:互斥锁;锁优化)

参考来源:
《Java并发编程的艺术》
《深入理解Java内存模型》
《深入理解Java虚拟机》
http://ifeve.com/java-memory-model-6/
http://tutorials.jenkov.com/java-concurrency/java-memory-model.html

(本文首发于微信公众号:EnjoyMoving)

https://wx4.sinaimg.cn/mw690/73036ef6ly1fwn6kdgxm8j20c00cfmye.jpg(公众号二维码)

猜你喜欢

转载自blog.csdn.net/zhaofuqiangmycomm/article/details/113835534
今日推荐