• 多线程(2)


    1.Java内存可见性

    1.1Java内存模型 

    JVM内存结构、Java对象模型和Java内存模型,这就是三个截然不同的概念,而这三个概念很容易混淆。这里详细区别一下 。

    1.1.1.JVM内存结构

    我们都知道,Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。其中有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。

     1.1.2.Java对象模型

    Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。HotSpot虚拟机中(Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机),设计了一个OOP-Klass Model。OOP(Ordinary ObjectPointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个 instanceKlass对象,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

     1.1.3.内存模型

    Java内存模型就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
    简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。

     JMM线程操作内存的基本的规则:
    第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行,不能直接从主内存中读写 。

    • 主内存

    主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

    • 本地内存

    主要存储当前方法的所有本地变量信息(本地内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的本地内存,即线程中的本地变量对其它线程是不可见的,就是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

    1.1.4.小结

    JVM内存结构,和Java虚拟机的运行时区域有关。 Java对象模型,和Java对象在虚拟机中的表现形式有关。 Java内存模型,和Java的并发编程有关。

    2.内存可见性

    2.1 内存可见性介绍

    可见性: 一个线程对共享变量值的修改,能够及时的被其他线程看到
    共享变量: 如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量
    线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
    1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
    2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量

     从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。 

    2.2.可见性问题

     1 public class Demo1Jmm {
     2 
     3     public static void main(String[] args) throws InterruptedException {
     4 
     5         JmmDemo demo = new JmmDemo();
     6         Thread t = new Thread(demo);
     7         t.start();
     8         Thread.sleep(100);
     9         demo.flag=false;
    10         //当把flag修改为false后,本应该跳出死循环
    11         System.out.println("已经修改为false");
    12         System.out.println(demo.flag);
    13 
    14     }
    15 
    16     static class JmmDemo implements Runnable {
    17         public boolean flag = true;
    18 //        public volatile boolean flag = true;
    19 
    20         public void run() {
    21             System.out.println("子线程执行。。。");
    22             while (flag) {
    23                 /*synchronized (this) {
    24                 }*/
    25             }
    26             System.out.println("子线程结束。。。");
    27         }
    28     }
    29 }
    View Code

    结果:

    子线程执行。。。
    已经修改为false
    false

    (1)可用synchronized锁来解决可见性问题:

     1 public class Demo1Jmm {
     2 
     3     public static void main(String[] args) throws InterruptedException {
     4 
     5         JmmDemo demo = new JmmDemo();
     6         Thread t = new Thread(demo);
     7         t.start();
     8         Thread.sleep(100);
     9         demo.flag=false;
    10         //当把flag修改为false后,本应该跳出死循环
    11         System.out.println("已经修改为false");
    12         System.out.println(demo.flag);
    13 
    14     }
    15 
    16     static class JmmDemo implements Runnable {
    17         public boolean flag = true;
    18 //        public volatile boolean flag = true;
    19 
    20         public void run() {
    21             System.out.println("子线程执行。。。");
    22             while (flag) {
    23                 synchronized (this) {
    24                 }
    25             }
    26             System.out.println("子线程结束。。。");
    27         }
    28     }
    29 }
    View Code

    结果:

    子线程执行。。。
    已经修改为false
    子线程结束。。。
    false

    可见当修改flag状态后,线程跳出死循环。

    (2)可用volatile锁来解决可见性问题:

     1 public class Demo1Jmm {
     2 
     3     public static void main(String[] args) throws InterruptedException {
     4 
     5         JmmDemo demo = new JmmDemo();
     6         Thread t = new Thread(demo);
     7         t.start();
     8         Thread.sleep(100);
     9         demo.flag=false;
    10         //当把flag修改为false后,本应该跳出死循环
    11         System.out.println("已经修改为false");
    12         System.out.println(demo.flag);
    13 
    14     }
    15 
    16     static class JmmDemo implements Runnable {
    17 //        public boolean flag = true;
    18         public volatile boolean flag = true;
    19 
    20         public void run() {
    21             System.out.println("子线程执行。。。");
    22             while (flag) {
    23                 /*synchronized (this) {
    24                 }*/
    25             }
    26             System.out.println("子线程结束。。。");
    27         }
    28     }
    29 }
    View Code

    结果:

    子线程执行。。。
    已经修改为false
    子线程结束。。。
    false

    3.synchronized

    synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程执行synchronized声明的代码块。还可以保证共享变量的内存可见性。同一时刻只有一个线程执行,这部分代码块的重排序也不会影响其执行结果。也就是说使用了synchronized可以保证并发的原子性,可见性,有序性。

    3.1. 解决可见性问题

    JMM关于synchronized的两条规定:
    (1)线程解锁前(退出同步代码块时):必须把自己工作内存中共享变量的最新值刷新到主内存中 
    (2)共享变量时需要从主内存中重新读取最新的值(加锁与解锁是同一把锁)

    做如下修改,在死循环中添加同步代码块

    while (flag) {
        synchronized (this) {
        }
    }

    synchronized实现可见性的过程:
    1. 获得互斥锁(同步获取锁)
    2. 清空本地内存
    3. 从主内存拷贝变量的最新副本到本地内存
    4. 执行代码
    5. 将更改后的共享变量的值刷新到主内存
    6. 释放互斥锁

    3.2.同步原理

    synchronized的同步可以解决原子性、可见性和有序性的问题,那是如何实现同步的呢?

    Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

    1. 普通同步方法,锁是当前实例对象this

    2. 静态同步方法,锁是当前类的class对象

    3. 同步方法块,锁是括号里面的对象

    当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁。

    3.3.锁优化 

    synchronized是重量级锁,效率不高。但在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了。jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销 锁状态,他们会随着竞争的激烈而逐渐升级。
    注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率 。

    3.4.Java常见锁

    3.4.1.基本概念 

    1. 乐观锁
       乐观锁是一种乐观思想,认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都任务别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(跟上一次的版本号做比较,如果一样则更新),如果失败就要重复读–比较—写的操作
       Java中的乐观岁基本都是通过CAS操作实现的,CAS是一种原子操作,比较当前值跟传入的值是否一样,一样则更新,否则失败
    2. 悲观锁
       1)悲观锁就是悲观思想,认为写多,遇到并发的可能性高,每次去读取数据的时候认为别人会修改,所以每次读写操作的时候都会上锁,这样别人想要读写这个数据就会等待直到拿到所.
       2)Java中的悲观锁就是synchronized,AQS框架下的锁时先尝试CAS乐观锁去获取锁,如果获取不到才转换为悲观锁.
    3. 自旋锁
       1) 原理很简单,如果持有锁的线程能在很短时间内释放锁资源,那么这些等待竞争锁的线程就不需要进入阻塞状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取所,这样就避免用户线程造成更多的消耗
       2)线程自旋是需要消耗CPU的,如果一直获取不到所,那线程也不可能一直消耗CPU,因此要设置一个自旋等待的最大时间,当持有锁的线程执行时间超过最大等待时间时,其他等待线程就会进入阻塞状态.
       3)自旋锁优点
        自旋锁尽可能的减少线程阻塞,这对于锁的竞争不激烈且占用锁时间非常短的代码块来说性能大幅度提升,因为自旋的消耗小于线程阻塞挂起再唤醒的操作消耗,这些操作会导致线程发生两次上下文切换
       4)自旋锁缺点
        如果锁竞争十分激烈,或者持有锁的线程需要长时间占用锁,这时候就不适合使用自旋锁了.因为自旋锁在获取锁前一直占用CPU做无用功,同时又大量线程在竞争一个锁,导致获取锁时间很长,线程自旋消耗大于线程阻塞挂起操作的消耗.
       5) 自旋锁最大等待时间阈值
        自旋锁的目的是为了占着CPU的资源不放,等到获取到锁立即执行.自等待时间的选择很重要.在JDK1.5中时间是写死的,在JDK1.6引入了适应性自旋锁,意味着自旋锁的时间不在固定了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间.
    4. 读写锁
      为了提高性能,Java提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提供了程序的执行效率.读写锁分为读锁和写锁,多个读锁不互斥,读锁和写锁互斥,这是由JVM控制的
       1)读锁
        如果你的代码只读数据,可能很多人同时读,但不能同时写,就可以上读锁
       2)写锁
        如果你的代码修改数据,只能一个人在写且不能同时读取,就上写锁.
       3)Java中读写锁有接口java.util.concurrent.locks.ReadWriteLock,也有具体实现ReentrantReadWriteLock
       4)使用读写锁的步骤

    4.Volatile

    通过前面内容我们了解了synchronized,虽然JVM对它做了很多优化,但是它还是一个重量级的锁。而接下来要介绍的volatile则是轻量级的synchronized。如果一个变量使用volatile,则它比使用synchronized的成本更加低,因为它不会引起线程下文的切换和调度。
    Java语言规范对volatile的定义如下:
    Java允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
    通俗点讲就是说一个变量如果用volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的,如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是内存可见性。
    volatile虽然看起来比较简单,使用起来无非就是在一个变量前面加上volatile即可,但是要用好并不容易。

    4.1 解决内存可见性问题

    在可见性问题案例中进行如下修改,添加volatile关键词:

    private volatile boolean flag = true; 

    线程写Volatile变量的过程:
    1. 改变线程本地内存中Volatile变量副本的值;
    2. 将改变后的副本的值从本地内存刷新到主内存
    线程读Volatile变量的过程:
    1. 从主内存中读取Volatile变量的最新值到线程的本地内存中
    2. 从本地内存中读取Volatile变量的副本
    Volatile实现内存可见性原理:
    写操作时,通过在写操作指令后加入一条store屏障指令,让本地内存中变量的值能够刷新到主内存中
    读操作时,通过在读操作前加入一条load屏障指令,及时读取到变量在主内存的值
    PS: 内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序 。

    4.2.原子性的问题

    虽然Volatile 关键字可以让变量在多个线程之间可见,但是Volatile不具备原子性。

    代码演示:

     1 public class Demo17 {
     2     public static void main(String[] args) throws InterruptedException {
     3         volidateDemo Demo = new volidateDemo();
     4 
     5         for (int i = 0; i < 3; i++) {
     6             Thread t = new Thread(Demo);
     7             t.start();
     8         }
     9         Thread.sleep(1000);
    10         System.out.println(Demo.count);
    11 
    12     }
    13 
    14     static class volidateDemo implements Runnable {
    15         private volatile int count;
    16 
    17         @Override
    18         public void run() {
    19             addCount();
    20         }
    21 
    22         private void addCount() {
    23             for (int i = 0; i <1000; i++) {
    24                 count++;
    25             }
    26         }
    27     }
    28 }
    View Code

    结果:

    2152

    本应该执行3000次,却执行了2152次。

    synchronized可以保证正原子性操作。

     1 public class Demo17 {
     2     public static void main(String[] args) throws InterruptedException {
     3         volidateDemo Demo = new volidateDemo();
     4 
     5         for (int i = 0; i < 3; i++) {
     6             Thread t = new Thread(Demo);
     7             t.start();
     8         }
     9         Thread.sleep(1000);
    10         System.out.println(Demo.count);
    11 
    12     }
    13 
    14     static class volidateDemo implements Runnable {
    15         private volatile int count;
    16 
    17         @Override
    18         public void run() {
    19             addCount();
    20         }
    21 
    22         private void addCount() {
    23             for (int i = 0; i <1000; i++) {
    24                 synchronized (this){
    25                     count++;
    26                 }
    27             }
    28         }
    29     }
    30 }
    View Code

    结果:

    3000

    4.3.Volatile 适合使用场景

    a)对变量的写入操作不依赖其当前值

    满足:boolean变量、直接赋值的变量等
    b)该变量没有包含在具有其他变量的不变式中
    不满足:不变式 low<up
    总结:变量真正独立于其他变量和自己以前的值,在单独使用的时候,适合用volatile 

    4.4.synchronizedvolatile比较

    avolatile不需要加锁,比synchronized更轻便,不会阻塞线程

    bsynchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性
    与锁相比,Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件(变量真正独立于其他变量和自己以前的值 ) 在某些情况下可以使用 volatile 替 synchronized 来优化代码提升效率。 

     5.ReentrantLock

    ReentrantLock,可重入锁,是一种递归无阻塞的同步机制。它可以等同于synchronized的使用,但是ReentrantLock提供了比synchronized更强大、灵活的锁机制,可以减少死锁发生的概率。ReentrantLock还提供了公平锁和非公平锁的选择,构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。ReentrantLockReentrantLock,可重入锁,是一种递归无阻塞的同步机制。它可以等同于synchronized的使用,但是ReentrantLock提供了比synchronized更强大、灵活的锁机制,可以减少死锁发生的概率。
    ReentrantLock还提供了公平锁和非公平锁的选择,构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

    5.1.获取锁

    一般都是这么使用ReentrantLock获取锁的:(默认非公平锁)

    //非公平锁
    ReentrantLock lock = new ReentrantLock();
    lock.lock();

    实例:

     1 public class Demo18 {
     2     public static void main(String[] args) throws InterruptedException {
     3         volidateDemo Demo = new volidateDemo();
     4 
     5         for (int i = 0; i < 3; i++) {
     6             Thread t = new Thread(Demo);
     7             t.start();
     8         }
     9         Thread.sleep(1000);
    10         System.out.println(Demo.count);
    11 
    12     }
    13 
    14     static class volidateDemo implements Runnable {
    15         private volatile int count;
    16 
    17         //非公平锁
    18         ReentrantLock lock = new ReentrantLock();
    19 
    20         @Override
    21         public void run() {
    22             addCount();
    23         }
    24 
    25         private void addCount() {
    26             for (int i = 0; i <1000; i++) {
    27                 //加锁
    28                 lock.lock();
    29                  count++;
    30                  //释放锁
    31                 lock.unlock();
    32             }
    33         }
    34     }
    35 }
    View Code

    结果:

    3000

    5.2.释放锁

    获取同步锁后,使用完毕则需要释放锁,ReentrantLock提供了unlock释放锁: 

    private void addCount() {
                for (int i = 0; i <1000; i++) {
                    //加锁
                    lock.lock();
                     count++;
                     //释放锁
                    lock.unlock();
                }
     }

    5.3.ReentrantLock与synchronized的区别

    前面提到ReentrantLock提供了比synchronized更加灵活和强大的锁机制,那么它的灵活和强大之处在哪里呢?他们之间又有什么相异之处呢?
    1. 与synchronized相比,ReentrantLock提供了更多,更加全面的功能,具备更强的扩展性。例如:时间锁等候,可中断锁等候,锁投票。
    2.ReentrantLock还提供了条件Condition,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,ReentrantLock更加适合(以后会阐述Condition)。
    3.ReentrantLock提供了可轮询的锁请求。它会尝试着去获取锁,如果成功则继续,否则可以等到下次运行时处理,而synchronized则一旦进入锁请求要么成功要么阻塞,所以相比synchronized而言,ReentrantLock会不容易产生死锁些。
    4.ReentrantLock支持更加灵活的同步代码块,但是使用synchronized时,只能在同一个synchronized块结构中获取和释放。注:ReentrantLock的锁释放一定要在finally中处理,否则可能会产生严重的后果。
    5. ReentrantLock支持中断处理,且性能较synchronized会好些。

    6.读写锁ReentrantReadWriteLock 

    但是在大多数场景下,大部分时间都是提供读服务,而写服务占有的时间较少。然而读服务不存在数据竞争问题,如果一个线程在读时禁止其他线程读势必会导致性能降低。所以就提供了读写锁。读写锁维护着一对锁,一个读锁和一个写锁。通过分离读锁和写锁,使得并发性比一般的互斥锁有了较大的提升:在同一时间可以允许多个读线程同时访问,但是在写线程访问时,所有读线程和写线程都会被阻塞。
    读写锁的主要特性:
    1. 公平性:支持公平性和非公平性。
    2. 重入性:支持重入。读写锁最多支持65535个递归写入锁和65535个递归读取锁。
    3. 锁降级:写锁能够降级成为读锁,遵循获取写锁、获取读锁在释放写锁的次序。读锁不能升级为写锁。

    读写锁ReentrantReadWriteLock实现接口ReadWriteLock,该接口维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。

    public interface ReadWriteLock {
        /**
         * Returns the lock used for reading.
         *
         * @return the lock used for reading
         */
        Lock readLock();
    
        /**
         * Returns the lock used for writing.
         *
         * @return the lock used for writing
         */
        Lock writeLock();
    }

    6.1.写锁的获取与释放

    只有等读锁完全释放后,写锁才能够被当前线程所获取,一旦写锁开始获取了,所有其他读、写线程均会被阻塞。 

     1 public class Demo19 {
     2     private static volatile int count = 0;
     3 
     4     public static void main(String[] args) {
     5         ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
     6         WriteDemo writeDemo = new WriteDemo(lock);
     7         ReadDemo readDemo = new ReadDemo(lock);
     8 
     9         for (int i = 0; i < 2; i++) {
    10             new Thread(writeDemo).start();
    11         }
    12         for (int i = 0; i < 2; i++) {
    13             new Thread(readDemo).start();
    14         }
    15     }
    16 
    17     static class WriteDemo implements Runnable {
    18         ReentrantReadWriteLock lock;
    19 
    20         public WriteDemo(ReentrantReadWriteLock lock) {
    21             this.lock = lock;
    22         }
    23 
    24         @Override
    25         public void run() {
    26             for (int i = 0; i < 3; i++) {
    27                 try {
    28                     TimeUnit.MILLISECONDS.sleep(1);
    29                 } catch (InterruptedException e) {
    30                     e.printStackTrace();
    31                 }
    32 
    33                 //只有等读锁完全释放后,写锁才能够被当前线程所获取,一旦写锁开始获取了,所有其他读、写线程均会被阻塞。
    34                 lock.writeLock().lock();
    35                 count++;
    36                 lock.writeLock().unlock();
    37             }
    38         }
    39     }
    40 
    41     static class ReadDemo implements Runnable {
    42 
    43         ReentrantReadWriteLock lock;
    44 
    45         public ReadDemo(ReentrantReadWriteLock lock) {
    46             this.lock = lock;
    47         }
    48 
    49         @Override
    50         public void run() {
    51             for (int i = 0; i < 3; i++) {
    52                 try {
    53                     TimeUnit.MILLISECONDS.sleep(1);
    54                 } catch (InterruptedException e) {
    55                     e.printStackTrace();
    56                 }
    57 
    58                 //读锁为一个可重入的共享锁,它能够被多个线程同时持有,在没有其他写线程访问时,读锁总是获取成功 。
    59                 lock.readLock().lock();
    60                 System.out.println(count);
    61                 lock.readLock().unlock();
    62             }
    63         }
    64     }
    65 }
    View Code

    6.2.读锁的获取与释放

    读锁为一个可重入的共享锁,它能够被多个线程同时持有,在没有其他写线程访问时,读锁总是获取成功 。


    6.3.锁降级

    读写锁有一个特性就是锁降级,锁降级就意味着写锁是可以降级为读锁的。锁降级
    需要遵循以下顺序:获取写锁=>获取读锁=>释放写锁 


  • 相关阅读:
    ApacheShiro反序列化远程代码执行 漏洞处理
    js判断是电脑(pc)访问还是手机(mobile)访问
    MySQL实现主从库,AB复制配置
    js实现回到顶部功能
    JAVA结合Redis处理缓存穿透问题
    Apache Shiro使用官方自带的生成AES密钥
    JAVA将Object数组转换为String数组
    JAVA中数组(Array)、字符串(String)、集合(List、Set)相互转换
    输入npm install 报错npm ERR! code ELIFECYCLE npm ERR! errno 1 npm ERR! node-sass@4.13.1 postinstall: `node scripts/build.js`
    tomcat的部署jspgou+优化
  • 原文地址:https://www.cnblogs.com/aaaazzzz/p/13473146.html
Copyright © 2020-2023  润新知