并发编程面试题

并发编程面试题,第1张

并发编程-基础 1、进程与线程的区别?

1、定义

进程是资源(CPU、内存等)分配的基本单位,进程是系统进行资源分配和调度的⼀个独立单位。

线程是进程的⼀个实体,是独立运行和独立调度的基本单位(CPU上真正运行的是线程)。

2、区别

  • ⼀个进程可以包含若干个线程。
  • 进程是资源分配的基本单位;线程是程序执行的基本单位。
  • 进程拥有自己的资源空间,每启动⼀个进程,系统就会为它分配地址空间;而线程与资源分配无关,多个线程共享同⼀进程内的资源,使⽤相同的地址空间(线程之间的通信更方便,同⼀进程下的线程共享全局变量、静态变量等数据)。
  • 线程的调度与切换比进程快很多,同时创建⼀个线程的开销也比进程要小很多。
2、什么是多线程中的上下文切换?

1、定义

CPU处理器给每个线程分配 CPU 时间片(Time Slice),线程在分配获得的时间片内执行任务。

当⼀个线程被暂停或剥夺CPU的使用权,另外的线程开始或者继续运行的这个过程就叫做上下文切换(Context Switch)

具体来说,⼀个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;⼀个线程被选中占用处理器开始或者继续运行,就是“切入”。在这种切出切入的过程中, *** 作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”。

2、上下文切换发生的时机

  1. 线程被分配的时间片用完;
  2. 使用synchronizedlock等也会导致上下文切换;
3、Java内存模型是什么?

JMM 就是Java内存模型(java memory model)

因为在不同的硬件生产商和不同的 *** 作系统下,内存的访问有⼀定的差异,所以会造成相同的JAVA代码运⾏在同的系统上会出现各种问题。所以java内存模型(JMM)屏蔽掉各种硬件和 *** 作系统的内存访问差异,以实现让java程序在各种平台下都能达到⼀致的并发效果。

Java 内存模型规定所有的变量都存储在主内存中(包括实例变量,静态变量)

每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量(局部变量)和主内存的副本拷贝,线程对变量的 *** 作都在工作内存中进行。线程不能直接读写主内存中的变量。

每个线程的工作内存都是独立 的,线程 *** 作数据只能在工作内存中进行,然后刷回到主存。这是 Java内存模型定义的线程基本工作方式。

不要把JMM与JVM的内存结构混淆了(堆、栈、程序计数器等)⼀般问JMM是想问多线程、并发相关的问题。

4、什么是原子 *** 作?在JUC中有哪些原子类 ?

原子 *** 作是指⼀个不受其他 *** 作影响的 *** 作任务单元。原子 *** 作是在多线程环境下避免数据不⼀致必须的⼿段。

比如:对在主内存的int进行++ *** 作就不是⼀个原子 *** 作,在JMM中,需要从主内存进⾏读取复制到⼯作内存,然后线程进行相加,最后把结果写回主内存。

所以当⼀个线程读取它的值并加 1 时,另外⼀个线程有可能会读到之前的值,这就会引发错误。

为了解决这个问题,必须保证增加 *** 作是原子的(确保 *** 作不受其他线程 *** 作的影响)

所以JDK中提供了很多原⼦ *** 作类:

AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference等等!

5、什么是CAS *** 作,缺点是什么?

CAS *** 作:Compare & Swap 或是 Compare & Set

在程序中我们使用CAS+自旋 的方式就可以实现原子 *** 作

CAS的缺点:

  1. 只能支持⼀个变量的原⼦ *** 作;
  2. CAS频繁失败导致CPU开销过高;
  3. ABA问题;

6、Java中的volatile变量有什么作用?
  1. 保证了不同线程对该变量 *** 作的内存可见性;
  2. 禁止指令重排序;

当线程写⼀个volatile变量时,JMM会立马把该线程对应的工作内存中的变量刷新到主内存

当线程读⼀个volatile变量时(如果变量已经在主内存被修改),JMM会把该线程对应的工作内存的变量置

为无效,线程接下来将从主内存中读取共享变量。

在不影响单线程程序执⾏结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化

7、volatile 变量和 atomic 变量有什么不同?

volatile:解决的是多线程可见性问题

Atomic:解决的是多线程安全问题

8、Lock接⼝(Lock interface)是什么?对比Synchronized它有什么优势?

Lock接⼝比同步方法和同步块( synchronized )提供了更具扩展性的锁 *** 作:

1、Lock 提供了无条件的、可轮询的(tryLock⽅法)、定时的(tryLock带参⽅法)、可中断(lockInterruptibly)、可多条件队列的(newCondition⽅法)锁 *** 作。

2、 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只⽀持非公平锁。

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进⼊队列去排队,永远都是队列的第⼀位才能获得锁。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进⼊等待队列,如果能获取到,就直接获取到锁。

9、乐观锁和悲观锁的理解及如何实现,有哪些实现方式?

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁,这样别⼈想拿这个数据就会阻塞直到它拿到锁。Java里面的同步原语synchronized关键字的实现是悲观锁。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断⼀下在此期间别人有没有去更新这个数据,可以使用版本号等机制。在Java中的原⼦变量类就是使用了乐观锁的⼀种实现方式CAS实现的。

10、什么是死锁?死锁的危害?

死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的⼀种互相等待的现象,若无外力作用,它们都将无法推进下去。

比如:以下代码在线程1和线程2同时运行情况下会发⽣死锁

危害:

  1. 死锁会使进程得不到正确的结果;
  2. 死锁会使资源的利用率降低;
  3. 死锁还会导致产⽣新的死锁;

11、什么是Callable和Future?

Callable接⼝类似于Runnable,从名字就可以看出来了,但是Runnable不会返回结果,并且无法抛出返回结果的异常。

Callable功能更强⼤⼀些,被线程执行后,可以返回值,这个返回值可以被Future拿到,也就是说,Future可以拿到异步执行任务的返回值。Callable可以认为是带有回调的Runnable。

Future接⼝表示异步任务,是还没有完成的任务给出的未来结果。所以说Callable用于产生结果,Future用于获取结果。

12、什么是FutureTask?它的底层原理?

FutureTask表示⼀个可以取消的异步运算。它有启动和取消运算、查询运算是否完成和取回运算结果等方法。只有当运算完成的时候结果才能取回,如果运算尚未完成get方法将会阻塞。

⼀个FutureTask对象可以对调⽤了CallableRunnable的对象进⾏包装,由于FutureTask也是调用了Runnable接⼝所以它可以提交给Executor来执⾏。

13、什么是阻塞队列?阻塞队列的实现原理是什么?

阻塞队列(BlockingQueue)是⼀个⽀持两个附加 *** 作的队列。

队列是⼀种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除 *** 作,而在表的后端(rear)进行插⼊ *** 作,和栈⼀样,队列是⼀种 *** 作受限制的线性表。进行插⼊ *** 作的端称为队尾,进行删除 *** 作的端称为队头。在队列中插⼊⼀个队列元素称为⼊队,从队列中删除⼀个队列元素称为出队。因为队列只允许在⼀端插入,在另⼀端删除,所以只有最早进⼊队列的元素才能最先从队列中删除,故队列⼜称为先进先出(FIFO—first in first out)线性表。

抛出异常:当队列满时,如果再往队列里插入元素,会抛出IllegalStateException("Queuefull")异常。当队列空时,从队列里获取元素会抛出NoSuchElementException异常。

  • 返回特殊值:当往队列插⼊元素时,会返回元素是否插⼊成功,成功返回true。如果是移除方法,则是从队列里取出⼀个元素,如果没有则返回null。
  • ⼀直阻塞:当阻塞队列满时,如果生成者线程往队列里put元素,队列会⼀直阻塞⽣产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里take元素,队列会阻塞住消费者线程,直到队列不为空。
  • 超时退出:当阻塞队列满时,如果生产者线程往队列里插⼊元素,队列会阻塞生产者线程⼀段时间,如果超过了指定的时间,生产者线程就会退出。

14、什么是不可变对象,它对写并发应⽤有什么帮助?

不可变对象(Immutable Objects)即对象⼀旦被创建它的状态(对象的数据,也即对象属性值)就不能改变,反之即为可变对象(Mutable Objects)。

Java平台类库中包含许多不可变类,如String、基本类型的包装类、BigIntegerBigDecimal等。不可变对象天⽣是线程安全的。它们的常量(域)是在构造函数中创建的。既然它们的状态⽆法修改,

这些常量永远不会变。

不可变对象永远是线程安全的。

只有满足如下状态,⼀个对象才是不可变的; 它的状态不能在创建后再被修改;

所有域都是 final 类型;并且,它被正确创建(创建期间没有发生 this 引用的逸出)。

15、在java中wait和sleep方法的不同?
  1. sleep 和 wait 线程阻塞状态
  2. sleep()方法导致了程序暂停执行指定的时间,让出 cpu 该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。
  3. 在调用 sleep()方法的过程中,线程不会释放对象锁
  4. 而当调用 wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用 notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。

16、为什么wait, notify 和 notifyAll这些方法不在thread类里面?

⼀个很明显的原因是 JAVA 提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。由于 wait,notify 和 notifyAll 都是锁级别的 *** 作, 所以把他们定义在 Object 类中因为锁属于对象。

17、 ThreadLocal有什么用?

黑马程序员Java基础教程由浅入深全面解析threadlocal_哔哩哔哩_bilibili

【Java基础】Java中引用类型 和 ThreadLocal_如约而至的重逢的博客-CSDN博客

ThreadLocal是Java里⼀种特殊的变量。每个线程都有⼀个ThreadLocal就是每个线程都拥有了自己独立的⼀个变量,竞争条件被彻底消除了。

它是为创建代价⾼昂的对象获取线程安全的好方法,比如你可以用 ThreadLocal 让 SimpleDateFormat 变成线程安全的,因为那个类创建代价高昂且每次调用都需要创建不同的实例所以不值得在局部范围使用它,如果为每个线程提供⼀个自己独有的变量拷贝,将大大提高效率。首先,通过复用减少了代价高昂的对象的创建个数。其次,你在没有使用高代价的同步或者不变性的情况下获得了线程安全。

18、ThreadLocal 和Synchronized的区别

虽然ThreadLocal 模式 与Synchronized关键字都用于处理多线程并发访问变量的问题, 不过两者处理问题的角度和思路不同

synchronized

ThreadLocal

原理

原理 同步机制采用以时间换空间的方式,只提供了一份变量, 让不同的线程排队访问

ThreadLocal采用以空间换时间的方式, 为每一个线程都提供了一份变量的副本, 从而实现同访问而相不干扰

侧重点

多个线程之间访问资源的同步

多线程中让每个线程之间的数据相互隔离

19、java中引用类型及特点

  1. 强引用: 最普通的引用 Object o = new Object()
  2. 软引用: 垃圾回收器, 内存不够的时候回收 (缓存)
  3. 弱引用: 垃圾回收器看见就会回收 (防止内存泄漏)wakeRef
  4. 虚引用: 垃圾回收器看见二话不说就回收,跟没有一样 (管理堆外内存) DirectByteBuffer -> 应用到NIO Netty

哪些场景使用到了弱引用,为什么使用弱引用?

设计缓存代码时候使用。

考虑下面的场景:现在有一个Product类代表一种产品,这个类被设计为不可扩展的,而此时我们想要为每个产品增加一个编号。一种解决方案是使用HashMap 。于是问题来了,如果我们已经不再需要一个 Product 对象存在于内存中(比如已经卖出了这件产品),假设指向它的引用为productA ,我们这时会给 productA 赋值为 null ,然而这时 productA 过去指向的 product 对象并不会被回收,因为它显然还被 HashMap 引用着。所以这种情况下,我有想要真正的回收一个 Product 对象,仅仅把它的强引用赋值为 null 是不够的,还要把相应的条目从HashMap中移除。显然“从日 HashMap 中移除不再需要的条目”,这个工作我们不想自己完成,我们希望告诉垃圾收集器:只有在 HashMap 中的 key 在引用着 Product 对象的情况下,就可以回收相应 Product 对象了。显然,根据前面弱引用的定义,使用弱引用能帮助我们达成这个目的。我们只需要用一个指向 Product 对象的弱引用对象来作为 HashMap 中的 key 就可以了。

20、请概述下AQS

是用来构建锁或者其他同步组件的基础框架,比如ReentrantLockReentrantReadWriteLockCountDownLatch就是基于AQS实现的。它使用了⼀个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。它是CLH队列锁的⼀种变体实现。

它可以实现2种同步方式:独占式,共享式。

AQS的主要使用方式是继承,子类通过继承AQS并实现它的抽象方法来管理同步状态,同步器的设计基于模板方法模式,所以如果要实现我们⾃⼰的同步⼯具类就需要覆盖其中几个可重写的方法如tryAcquiretryReleaseShared等等。

这样设计的目的是同步组件(比如锁)是面向使用者的,它定义了使用者与同步组件交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层 *** 作。这样就很好地隔离了使用者和实现者所需关注的领域。

在内部,AQS维护⼀个共享资源state,通过内置的FIFO队列来完成获取资源线程的排队⼯作。该队列由⼀个⼀个的Node结点组成,每个Node结点维护⼀个prev引⽤和next引⽤,分别指向自己的前驱和后继结点,构成⼀个双端双向链表。同时与Condition相关的等待队列,节点类型也是Node,构成⼀个单向链表。

21、单例模式的双重检查锁定(DCL)的单例是什么?

DCL单例模式中的DCL为Double Check Lock缩写,即双重检查锁定,是单例模式中线程安全的懒汉式单例模式,下面我们来分析一下这种单例模式的意义何在。


第一步:使用私有的构造函数,单例模式常规写法,防止外部通过构造方法初始化,统一初始化入口。
第二步:同步代码块外加判空条件,这个判空是为了程序效率,全掉后导致效率变低。因为去掉之后,不管instance是否已经初始化,都会进行synchronized *** 作,而synchronized是一个重 *** 作消耗性能。加上之后,如果已经初始化直接返回结果,不会进行synchronized *** 作。
第三步:初始化实例代码加上synchronized是为了防止多个线程同时调用getInstance方法时,各初始化instance一遍的并发问题。
第四步:同步代码块里加判空条件,为了防止多线程情况下多次初始化实例,假设两个线程a和b都通过了第一个判空条件。此时假设a先获得锁,进入synchronized的代码块,初始化instance,a释放锁。接着b获得锁,进入synchronized的代码块,也直接初始化instance,导致初始化了两次,加上判空就可以防止这种情况发生
 

/**
 * 双重检查方式
 */
public class Singleton { 

    //私有构造方法
    private Singleton() {}

    private static volatile Singleton instance;

   //对外提供静态方法获取该对象
    public static Singleton getInstance() {
		//第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
        if(instance == null) {
            synchronized (Singleton.class) {
                //抢到锁之后再次判断是否为null
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Synchronized 1、Synchronized实现原理 JDK1.6之前

Synchronized 在jdk1.6之前是重量级锁,Synchronized在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就 Synchroized 它是一个关键字,它是一个悲观锁,非公平锁,它底层采用monitor进行监控,当这个对象获取到锁,就进入monitorentry,当对象退出monitorexit,当放生异常也会进入到monitorexit

  1. JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下 ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将一部分线程移动EntryList 中作为候选竞争线程。
  2. Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程)。
  3. Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM 中,也把这种选择行为称之为“竞争切换”。
  4. OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者 notifyAll 唤醒,会重新进去 EntryList 中。
  5. 处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由 *** 作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的)。

JDK1.6之后

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的。

偏向锁

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。

偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID。

以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。

原理

当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:

  1. 虚拟机将会把对象头中的标志位设为“01”,即偏向模式。
  1. 同时使用CAS *** 作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果通过CAS *** 作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步 *** 作,偏向锁的效率高。

轻量级锁

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁.

原理

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced MarkWord),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
  1. JVM利用CAS *** 作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步 *** 作。
  2. 如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在 *** 作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起 *** 作系统层面的线程可能会得不偿失,毕竟 *** 作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是10个循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在 *** 作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此 StringBuffer 不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

public static void main(String[] args) { 
    int size = 10000;
    List list = new ArrayList();
    for (int i = 0; i < size; i++) {
        list.add(appendStr("hi", i));
    } 
}
public static String appendStr(String str, int i) {
    StringBuffer sb= new StringBuffer();
    sb.append(str);
    sb.append(i);
    return sb.toString();
}


 

2、为什么要引入偏向锁和轻量级锁?为什么重量级锁开销大?

重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t(互斥锁)来实现。

这些底层的同步函数 *** 作会涉及到: *** 作系统用户态和内核态的切换、进程的上下文切换,而这些 *** 作都是比较耗时的,因此重量级锁 *** 作的开销比较大。

而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。

 

3、偏向锁有撤销、膨胀,性能损耗这么大为什么要用呢?

偏向锁的好处是在只有一个线程获取锁的情况下,只需要通过一次 CAS *** 作修改 markword ,之后每次进行简单的判断即可,避免了轻量级锁每次获取释放锁时的 CAS *** 作。

如果确定同步代码块会被多个线程访问或者竞争较大,可以通过 -XX:-UseBiasedLocking 参数关闭偏向锁。

4、偏向锁、轻量级锁、重量级锁分别对应了什么使用场景?
  • 偏向锁

适用于只有一个线程获取锁。当第二个线程尝试获取锁时,即使此时第一个线程已经释放了锁,此时还是会升级为轻量级锁。但是有一种特例,如果出现偏向锁的重偏向,则此时第二个线程可以尝试获取偏向锁。

  • 轻量级锁

适用于多个线程交替获取锁。跟偏向锁的区别在于可以有多个线程来获取锁,但是必须没有竞争,如果有则会升级会重量级锁。有同学可能会说,不是有自旋,请继续往下看。

  • 重量级锁

适用于多个线程同时获取锁。

5、自适应自旋是如何体现自适应的?

自适应自旋锁有自旋次数限制,范围在:10。

如果当次自旋获取锁成功,则会奖励自旋次数1次,如果当次自旋获取锁失败,则会惩罚扣掉次数2次。所以如果自旋一直成功,则JVM认为自旋的成功率很高,值得多自旋几次,因此增加了自旋的尝试次数。相反的,如果自旋一直失败,则JVM认为自旋只是在浪费时间,则尽量减少自旋。














 

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/794295.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-06
下一篇 2022-05-06

发表评论

登录后才能评论

评论列表(0条)

保存