Java开发面试_多线程并发篇

目录

进程线程区别,线程安全和非线程安全区别

线程状态,start,run,wait,notify,yiled,sleep,join等方法的作用以及区别

wait,notify,notifyAll阻塞唤醒确切过程?

守护线程,线程中断

Java乐观锁机制,CAS思想?缺点?是否原子性?如何保证?

synchronized使用方法?底层实现?

ReenTrantLock使用方法?底层实现?和synchronized区别?

公平锁和非公平锁区别?为什么公平锁效率低?

锁优化。自旋锁、自适应自旋锁、锁消除、锁粗化、偏向锁、轻量级锁、重量级锁解释

Java内存模型

volatile作用?底层实现?单例模式中volatile的作用?

线程池构造函数7大参数,线程处理任务过程,线程拒绝策略

Execuors类实现的几种线程池类型,阿里为啥不让用?

线程池大小如何设置?

手写简单的线程池,体现线程复用

手写消费者生产者模式

手写多线程交替打印ABC


进程线程区别,线程安全和非线程安全区别

 进程是程序的运行过程,是资源分配的基本单位,进程中可以包含多个线程,多个线程共享进程中堆、方法区资源

​ 线程是cpu任务调度的最小执行单位,每个线程拥有自己独立的程序计数器、虚拟机栈、本地方法栈

  • 线程安全:多个线程对同一资源操作,不会互相影响
  • 非线程安全:多个线程对同一资源操作,会互相影响

线程状态,start,run,wait,notify,yiled,sleep,join等方法的作用以及区别

线程状态:创建、就绪、运行、阻塞、死亡

方法

作用

区别

start

启动线程,由虚拟机自动调度执行run()方法

线程处于就绪状态

run

线程逻辑代码块处理

JVM调度执行 线程处于运行状态

sleep

让当前正在执行的线程休眠(暂停执行)

不释放锁

wait

使得当前线程等待

释放同步锁

notify

唤醒在此对象监视器上等待的单个线程

唤醒单个线程

notifyAll

唤醒在此对象监视器上等待的所有线程

唤醒多个线程

yiled

停止当前线程,让同等优先权的线程运行

用Thread类调用

join

使当前线程停下来等待,直至另一个调用join方法的线程终止

用线程对象调用

wait,notify,notifyAll阻塞唤醒确切过程?

在哪阻塞,在哪唤醒?为什么要出现在同步代码块中?

阻塞

​ 这三个方法的调用都会使当前线程阻塞。该线程将会被放置到对该Object的请求等待队列中,然后让出当前对Object所拥有的所有的同步请求。线程会一直暂停所有线程调度,直到下面其中一种情况发生:

① 其他线程调用了该Object的notify方法,而该线程刚好是那个被唤醒的线程;

② 其他线程调用了该Object的notifyAll方法;

唤醒

​ 线程将会从等待队列中移除,重新成为可调度线程。它会与其他线程以常规的方式竞争对象同步请求。一旦它重新获得对象的同步请求,所有之前的请求状态都会恢复,也就是线程调用wait的地方的状态。线程将会在之前调用wait的地方继续运行下去。

原因

​ 由于wait()属于Object方法,调用之后会强制释放当前对象锁,所以在wait() 调用时必须拿到当前对象的监视器monitor对象。因此,wait()方法在同步方法/代码块中调用。

守护线程,线程中断

守护线程

​ t.setDaemon(true)为守护线程,也叫精灵线程,若主线程启动t线程,则t线程是主线程的守护线程,当主线程执行完了,则守护线程也随之结束。

public class ThreadDaemon extends Thread{
 
    public ThreadDaemon(String name){
        super(name);
    }
 
    @Override
    public void run() {
        while(true){
            System.out.println(Thread.currentThread().getName() + "线程运行了。。。");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
 
    public static void main(String[] args) {
        Thread t1 = new ThreadDaemon("线程一");
        Thread t2 = new ThreadDaemon("线程二");
        //设置为守护线程
        t1.setDaemon(true);
        t2.setDaemon(true);
        //启动线程
        t1.start();
        t2.start();
        //主线程2s后退出
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
 
 
}

线程中断

 t.interrupt();调用interrupt()不会让线程立即中断,只是线程的中断状态发生变化,系统会在后续中断该线程

public class ThreadInterrupt extends Thread{
 
    public ThreadInterrupt(String name){
        super(name);
    }
 
    @Override
    public void run() {
        while(!interrupted()){//中断状态判断
            System.err.println(Thread.currentThread().getName() + "线程运行了。。。");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
 
    public static void main(String[] args) {
        Thread t1 = new ThreadInterrupt("线程一");
        Thread t2 = new ThreadInterrupt("线程二");
        //启动线程
        t1.start();
        t2.start();
        t1.interrupt();
    }
 
 
}

Java乐观锁机制,CAS思想?缺点?是否原子性?如何保证?

乐观锁的机制

    乐观锁体现的是悲观锁的反面。它是一种积极的思想,它总是认为数据是不会被修改的,所以是不会对数据上锁的。但是乐观锁在更新的时候会去判断数据是否被更新过。乐观锁的实现方案一般有两种(版本号机制和CAS)。乐观锁适用于读多写少的场景,这样可以提高系统的并发量。在Java中 java.util.concurrent.atomic下的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

  乐观锁,大多是基于数据版本 (Version)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来 实现。 读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提 交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据 版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

CAS思想

 CAS就是compare and swap(比较交换),是一种很出名的无锁的算法,就是可以不使用锁机制实现线程间的同步。使用CAS线程是不会被阻塞的,所以又称为非阻塞同步。CAS算法涉及到三个操作:

  • ​ 需要读写内存值V;
  • ​ 进行比较的值A;
  • ​ 准备写入的值B

​ 当且仅当V的值等于A的值等于V的值的时候,才用B的值去更新V的值,否则不会执行任何操作(比较和替换是一个原子操作-A和V比较,V和B替换),一般情况下是一个自旋操作,即不断重试

缺点

 ABA问题-知乎

​ 高并发的情况下,很容易发生并发冲突,如果CAS一直失败,那么就会一直重试,浪费CPU资源

原子性

功能限制CAS是能保证单个变量的操作是原子性的,在Java中要配合使用volatile关键字来保证线程的安全;当涉及到多个变量的时候CAS无能为力;除此之外CAS实现需要硬件层面的支持,在Java的普通用户中无法直接使用,只能借助atomic包下的原子类实现,灵活性受到了限制

synchronized使用方法?底层实现?

使用方法:主要的三种使⽤⽅式

(1) 修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁

(2) 修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管new了多少个对象,只有⼀份)。所以如果⼀个线程A调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程B需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁

(3) 修饰代码块: 指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。

总结:synchronized锁住的资源只有两类:一个是对象,一个是类。

底层实现

​ 对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark Word 和 Class Metadata Address组成,其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息,Class Metadata Address是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例。

​ 锁也分不同状态,JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。

 每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

ReenTrantLock使用方法?底层实现?和synchronized区别?

介绍

​ 由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下三项:

1.等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。通过lock.lockInterruptibly()来实现这个机制。

2.公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好

3.锁绑定多个条件,一个ReentrantLock对象可以同时绑定对个对象。ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。

使用方法

 基于API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成

底层实现

 ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。

和synchronized区别

 1、底层实现上来说:synchronized JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),对象只有在同步块或同步方法中才能调用wait/notify方法;ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁;ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。

 2、是否可手动释放:synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过lock()和unlock()方法配合try/finally语句块来完成,使用释放更加灵活。

3、是否可中断synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。

 4、是否公平锁:synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。

公平锁和非公平锁区别?为什么公平锁效率低?

公平锁

​ 公平锁自然是遵循FIFO(先进先出)原则的,先到的线程会优先获取资源,后到的会进行排队等待。

​ 优点:所有的线程都能得到资源,不会饿死在队列中。

​ 缺点:吞吐量会下降,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销大

非公平锁

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

​ 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

​ 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁。

公平锁效率低原因

​ 公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在 wait,如果有自己要挂起,加到队列后面,然后唤醒队列最前面线程。这种情况下相比较非公平锁多了一次挂起和唤醒

​ 线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

锁优化。自旋锁、自适应自旋锁、锁消除、锁粗化、偏向锁、轻量级锁、重量级锁解释

锁优化

【1】减少锁的时间

​ 不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

​ 【2】减少锁的粒度

​ 它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;java中很多数据结构都是采用这种方法提高并发操作的效率,比如:

​ ConcurrentHashMap:

​ java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组:Segment< K,V >[] segments

​ Segment继承自ReenTrantLock,所以每个Segment是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

【3】锁粗化

​ 大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;

​ 在以下场景下需要粗化锁的粒度:

​ 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

【4】使用读写锁

​ ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可并发读,写操作使用写锁,只能单线程写

​ 【5】使用CAS

​ 如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+CAS操作会是非常高效的选择;

自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

​缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销;自旋次数默认值是10

自适应自旋锁

 对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点

锁消除

锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。

锁粗化

假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

偏向锁

 所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程;也就是说,这个线程已经占有这个锁,当他在次试图去获取这个锁的时候,他会已最快的方式去拿到这个锁,而不需要在进行一些monitor操作,因此这方面他是会对性能有所提升的,因为在大部分情况下是没有竞争的,所以锁此时是没用的,所以使用偏向锁是可以提高性能的;

重量级锁

重量级锁的加锁、解锁过程和轻量级锁差不多,区别是:竞争失败后,线程阻塞,释放锁后,唤醒阻塞的线程,不使用自旋锁,不会那么消耗CPU,所以重量级锁适合用在同步块执行时间长的情况下。

Java内存模型

​ Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

​ JMM 是一种规范,是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。

​ 所以,Java 内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。我们前面提到,并发编程要解决原子性、有序性和一致性的问题。

原子性

​ 在 Java 中,为了保证原子性,提供了两个高级的字节码指令 Monitorenter 和 Monitorexit。这两个字节码,在 Java 中对应的关键字就是 Synchronized。因此,在 Java 中可以使用 Synchronized 来保证方法和代码块内的操作是原子性的。

可见性

​ Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。Java 中的 Volatile 关键字修饰的变量在被修改后可以立即同步到主内存。被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 Volatile 来保证多线程操作时变量的可见性。除了 Volatile,Java 中的 Synchronized 和 Final 两个关键字也可以实现可见性。只不过实现方式不同

有序性

​ 在 Java 中,可以使用 Synchronized 和 Volatile 来保证多线程之间操作的有序性。区别:Volatile 禁止指令重排。Synchronized 保证同一时刻只允许一条线程操作。

volatile作用?底层实现?单例模式中volatile的作用?

作用

​ 保证数据的“可见性”:被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

​ 禁止指令重排:在多线程操作情况下,指令重排会导致计算结果不一致

底层实现

​ “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2)它会强制将对缓存的修改操作立即写入主存;

3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

单例模式中volatile的作用

防止代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

class Singleton{
    private volatile static Singleton instance = null;   //禁止指令重排
    private Singleton() {
 
    }
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

线程池构造函数7大参数,线程处理任务过程,线程拒绝策略

public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,
    TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,
    RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||maximumPoolSize <= 0 ||maximumPoolSize < corePoolSize ||
                keepAliveTime < 0)
             throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.corePoolSize = corePoolSize;
           this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
}

参数

作用

corePoolSize

核心线程池大小

maximumPoolSize

最大线程池大小

keepAliveTime

线程池中超过 corePoolSize 数目的空闲线程最大存活时间;可以allowCoreThreadTimeOut(true) 使得核心线程有效时间

TimeUnit

keepAliveTime 时间单位

threadFactory

新建线程工厂

RejectedExecutionHandler

当提交任务数超过 maxmumPoolSize+workQueue 之和时,任务会交给RejectedExecutionHandler 来处理

workQueue

阻塞任务队列

线程拒绝策略

​ 线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。

JDK 内置的拒绝策略如下

AbortPolicy

直接抛出异常,阻止系统正常运行

CallerRunsPolicy

只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。

DiscardOldestPolicy

丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务

DiscardPolicy

该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案

线程处理任务过程

当线程池小于corePoolSize,新提交任务将创建一个新线程执行任务,即使此时线程池中存在空闲线程。

当线程池达到corePoolSize时,新提交任务将被放入 workQueue 中,等待线程池中任务调度执行。

当workQueue已满,且 maximumPoolSize 大于 corePoolSize 时,新提交任务会创建新线程执行任务。

当提交任务数超过 maximumPoolSize 时,新提交任务由 RejectedExecutionHandler 处理。

当线程池中超过corePoolSize 线程,空闲时间达到 keepAliveTime 时,关闭空闲线程 。

Execuors类实现的几种线程池类型,阿里为啥不让用?

Executors.newSingleThreadExecutor():只有一个线程的线程池,因此所有提交的任务是顺序执行,适用于一个一个任务执行的场景

Executors.newCachedThreadPool():线程池里有很多线程需要同时执行,老的可用线程将被新的任务触发重新执行,如果线程超过60秒内没执行,那么将被终止并从池中删除,适用执行很多短期异步的小程序或者负载较轻的服务

  • Executors.newFixedThreadPool():拥有固定线程数的线程池,如果没有任务执行,那么线程会一直等待,适用执行长期的任务,性能好很多。

Executors.newScheduledThreadPool():用来调度即将执行的任务的线程池

因为以上方式都存在弊端:

​ FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列⻓度为 Integer.MAX_VALUE,可能堆积⼤量的请求,从⽽导致OOM。

​ CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE,可能会创建⼤量线程,从⽽导致OOM。

线程池大小如何设置?

CPU 密集型

  • CPU 密集的意思是该任务需要大量的运算,而没有阻塞,CPU 一直全速运行。
  • CPU 密集型任务尽可能的少的线程数量,一般为 CPU 核数 + 1 个线程的线程池。
  • IO 密集型

 

  • 由于 IO 密集型任务线程并不是一直在执行任务,可以多分配一点线程数,如 CPU * 2

也可以使用公式:CPU 核数 / (1 - 阻塞系数);其中阻塞系数在 0.8 ~ 0.9 之间。

 

手写简单的线程池,体现线程复用

手写消费者生产者模式

手写阻塞队列

手写多线程交替打印ABC

 

 

猜你喜欢

转载自blog.csdn.net/weixin_37841366/article/details/109118152
今日推荐