Java基础 - 多线程

多线程

并行和并发

这里的时间都是微观上的概念

  • 并行:指两个或多个事件在同一时刻发生,强调的是时间点的瞬间
  • 并发:指两个或多个事件在一个时间段内(时间很短,如1纳秒内)先后发生,强调的是时间段

当系统只有一个CPU时,线程会以某种顺序执行多个线程,我们把这种情况称之为线程调度,这种情况下,线程是一个接一个执行的。

进程和线程

  • 进程:指一个内存中运行中的应用程序.

    有独立的内存空间,进程中的数据存放空间(堆空间和栈空间)是独立的,至少有一个线程。

  • 线程:指进程中的一个执行任务(控制单元),一个进程可以同时并发运行多个线程.

    堆空间是共享的,栈空间是独立的,线程消耗的资源也比进程小,相互之间可以影响的,又称为轻型进程或进程元。

进程中的多个线程是并发执行的,从微观上来讲,线程执行是有先后顺序的,那么哪个线程执行完全取决于CPU调度器,程序员是控制不了的。

我们可以把多线程并发性看作是多个线程在瞬间抢CPU资源,谁抢到资源谁就运行,这也造就了多线程的随机性。

Java程序的进程里至少包含主线程和垃圾回收线程(后台线程)。

多线程的优势

多线程作为一种多任务、并发的工作方式,当然有其存在优势:

  • 提高应用程序响应:

这对图形界面的程序尤其有意义,当一个操作耗时很长时,整个系统都会等待这个操作,此时程序不会响应键盘、鼠标、菜单的操作,而使用多线程技术,将耗时长的操作置于一个新的线程,可以避免这种尴尬的情况

  • 使多CPU系统更加有效:

操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上

  • 改善程序结构:

一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改。

Java操作进程

Runtime:

  • 使java应用程序与其运行环境相连接,可以通过getRuntime()获取当前运行时.
  • 通过exec(String command)方法 执行应用程序.

多线程的创建

继承Thread类:

  1. 自定义类继承于Thread类,那么该自定义类就是线程类;
  1. 覆写run方法,将线程运行的代码存放在run中;
  1. 创建自定义类的对象,即线程对象;
  1. 调用线程对象的start方法,启动线程。

实现Runnable接口:

  1. 自定义类实现Runnable接口;
  1. 覆写run方法,线程运行的代码存放在run中;
  1. 通过Thread类创建线程对象,并将实现了Runnable接口的实现类对象作为参数传递给Thread类的构造器。
  1. Thread类对象调用start方法,启动线程。

继承和实现的对比:

  • 都能完成同样的功能
  • 使用继承的方式比较方便
  • Java中继承的机制的单继承,即继承Thread后,不能再继承其他的类,但是Java对于接口是多实现的,实现Runnable后依然可以实现其他接口
  • 从面向对象的角度来看,把线程的任务代码单独的放在Runnable接口中,以参数的形式传递Runable对象到Thread中,实现线程的任务代码和线程对象分离,保留了继承,更符合面向对象的思想

    推荐使用实现Runnable接口的方式来创建线程

线程并发的安全问题

多个线程访问共享区域(堆),产生了数据不安全的问题(不同步).

场景:

当线程A只执行了变量值的修改,还未打印变量的值,此时线程的执行权被其他的线程B所抢走,别的线程又修改了变量的值,并打印了变量值,当原来的线程A抢回执行权后变量的值已经被别的线程B所修改,线程A打印的值就与线程B打印的值相同, 所以就有了数据重复的问题

解决安全问题的方法:

  • 同步代码块

      synchronized(同步监听对象) {
          ....
      }
    

    同步监听对象,只要是一个对象即可,需要有同步的效果,该对象就必须是唯一对象

    凡是操作了共享数据的代码必须同步起来

    同步监听对象就像一把锁一样,一个线程进去代码块后就会使用锁把门给锁上,别的线程就进不去了,只能在门口等着,直到代码块里的线程执行完毕,把锁释放之后,别的线程才能进去,至于是哪个线程进去,由CPU的调度决定.

  • 同步方法

      public synchronized void m(){
          ...
      }
    

    和同步代码块是一样的,仅仅是形式不同而已

    静态方法的同步监听对象是当前类的字节码对象 类名.class

    实例方法的同步监听对象是:this

懒汉式的单例模式:

private static $CLASS_NAME$ instance ;

private $CLASS_NAME$($param1$){
$init$
}

public static $CLASS_NAME$ getInstance($param1$){
    //如果有多个线程进来后,1个线程进入后,其他线程被堵在外面,效率比较差,增加一个null判断.
    if (instance == null) {
        synchronized ($CLASS_NAME$.class){
            if (instance == null) {
                instance = new $CLASS_NAME$($param2$) ;
             }
        }
    }
    return instance ;
}
  • 锁机制

    Lock接口

      可以自由的控制什么时候上锁,什么时候释放锁
    
      lock();//获取锁
    
      unlock();//释放锁
    

    ReentrantLock

      一个可重入的互斥锁,功能比sychronized更强大
    

    示例代码:

      Lock lock = new ReentrantLock();
      public void run(){
          while(true){
              lock.lock();//执行操作共享数据前上锁
              try{
    
                  .....//执行数据操作
    
              }catch(Exception e){
                  
              }finally{
                  lock.unlock();//执行完毕释放锁
              }
          }
      }
    

生产消费者案例

由生产者(producer)往共享空间存放数据,再由消费者(consumer )取出数据

共享数据
public void add(String name, String taste) {
    try {
        this.name = name;
        System.out.println("做好了菜:"+name);
        Thread.sleep(5);
        this.taste = taste;
        System.out.println("调味后生产了一道菜:" + name + "   口味:" + taste);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

}

public void take() {
    try {
        Thread.sleep(5);
        System.out.println("消费了一道菜:" + name + "   口味:" + taste);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

}

生产消费者线程

public  void main(){
    //共享的数据区域
    ShareData data = new ShareData();
    //生产者线程
    new Thread(new Producer(data)).start();
    //消费者线程
    new Thread(new Consumer(data)).start();
}

public class Producer implements Runnable{
    private ShareData mShareData;

    public Producer(ShareData shareData) {
        mShareData = shareData;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if (i % 2 == 0){
                mShareData.add("麻辣小龙虾","麻辣");
            }else{
                mShareData.add("清蒸排骨","清淡");
            }
        }
    }
}

public class Consumer implements Runnable{
    private ShareData mShareData;

    public Consumer(ShareData shareData) {
        mShareData = shareData;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            mShareData.take();
        }
    }
}

出现问题:

 做好了菜:麻辣小龙虾
 调味后生产了一道菜:麻辣小龙虾   口味:麻辣
 做好了菜:清蒸排骨
 调味后生产了一道菜:清蒸排骨   口味:清淡
 做好了菜:麻辣小龙虾
 消费了一道菜:麻辣小龙虾   口味:清淡
 调味后生产了一道菜:麻辣小龙虾   口味:麻辣
 做好了菜:清蒸排骨
 消费了一道菜:清蒸排骨   口味:麻辣
 调味后生产了一道菜:清蒸排骨   口味:清淡
 做好了菜:麻辣小龙虾
 消费了一道菜:麻辣小龙虾   口味:清淡
 调味后生产了一道菜:麻辣小龙虾   口味:麻辣
 做好了菜:清蒸排骨
 消费了一道菜:清蒸排骨   口味:麻辣
 调味后生产了一道菜:清蒸排骨   口味:清淡
 做好了菜:麻辣小龙虾
 消费了一道菜:麻辣小龙虾   口味:清淡
 调味后生产了一道菜:麻辣小龙虾   口味:麻辣
 做好了菜:清蒸排骨
 消费了一道菜:清蒸排骨   口味:麻辣
 调味后生产了一道菜:清蒸排骨   口味:清淡
  1. 先消费,后生产

    生产者已经生产好后,正准备打印的时候,CPU调度了消费者的线程,生产者的线程停下来了,先由消费者消费,自然出现了先消费的现象

    解决方案:

      必须先等生产者生产好后,再由消费者消费
    
  2. 多消费,多生产

    生产的线程在第一执行时被抢了,先出现先消费的情况后,下一次执行时,比较顺利就会看到生次的情况,同样的道理,对于消费者来讲,就会出现消费两次的情况

    解决方案:

      必须先等生产者生产好后,再由消费者消费
    
  3. 菜和口味不符

    在生产者生产的时候,只做了菜名,没有上调料的时候,线程的执行被消费者抢了,
    消费者访问到的是新做的菜和上次留下的调料,出现了口味的错乱的问题

    解决方案:

      必须先等生产者生产好后,再由消费者消费,需要加上同步
    

解决方案一:

1.要保证同步

使用同步的方法

2.要保证生产者先执行

先设置一个标志判断是否有东西,没有就执行生产者,否则执行消费

3.要保证线程中的内容执行完毕,另一个线程才能执行

使用等待唤醒机制(Object)

Object的等待唤醒:

wait(); //使当前线程进入等待, 必须由当前线程的同步监听对象调用

notifyAll();//唤醒在同步对象上等待的所有线程,必须由当前线程的同步监听对象调用

改良后的代码:

public class ShareData {
private String name;
private String taste;
private boolean hasData;
//同步方法
public synchronized void add(String name, String taste) {
    try {
        if (hasData){
            //存在数据,生产者线程进入等待
            this.wait();
        }
        this.name = name;
        System.out.println("做好了菜:"+name);
        Thread.sleep(5);
        this.taste = taste;
        System.out.println("调味后生产了一道菜:" + name + "   口味:" + taste);
        //生产完毕,改变标记,唤醒消费者线程
        hasData = true;
        this.notifyAll();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

}

//同步方法
public synchronized void take() {
    try {
        if (!hasData){
            //无数据,消费者线程等待
            this.wait();
        }
        Thread.sleep(5);
        System.out.println("消费了一道菜:" + name + "   口味:" + taste);
        //消费完毕,改变标记,唤醒生产者线程
        hasData = false;
        this.notifyAll();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

}
}

解决方案二:

使用锁的方式

Condition

  • 锁的同步监听对象, Lock代替了Synchronized方法和语句的使用, Condition代替了Object中的同步监听对象.

    await() //造成当前线程在接到信号或者被中断之前一直处于等待状态,替代Object的wait();

    singalAll() //唤醒所有等待线程, 替代Object的notifyAll();

    newCondition() //Lock接口的方法创建Condition对象

示例代码:

public class ShareDataForLock {
private String name;
private String taste;
private boolean hasData;
//必须先创建锁对象
private Lock mLock;
//锁的同步监听对象
private Condition mCondition;

public ShareDataForLock() {
    mLock = new ReentrantLock();
    mCondition = mLock.newCondition();
}

public  void add(String name, String taste) {
    mLock.lock();//加锁
    try {
        if (hasData){
            //存在数据,生产者线程进入等待
            mCondition.await();
        }
        this.name = name;
        System.out.println("做好了菜:"+name);
        Thread.sleep(5);
        this.taste = taste;
        System.out.println("调味后生产了一道菜:" + name + "   口味:" + taste);
        //生产完毕,改变标记,唤醒消费者线程
        hasData = true;
        mCondition.signalAll();
    } catch (InterruptedException e) {
        e.printStackTrace();
        mLock.unlock();//释放锁
    }

}

//同步方法
public synchronized void take() {
    mLock.lock();//加锁
    try {
        if (!hasData){
            //无数据,消费者线程等待
            mCondition.await();
        }
        Thread.sleep(5);
        System.out.println("消费了一道菜:" + name + "   口味:" + taste);
        //消费完毕,改变标记,唤醒生产者线程
        hasData = false;
        mCondition.signalAll();
    } catch (InterruptedException e) {
        e.printStackTrace();
        mLock.unlock();//释放锁
    }

}}

睡眠和等待的区别

Thread的sleep方法和Object的wait方法

1:他们都能让线程暂停执行

2:sleep方法在暂停执行的过程中是不会失去同步监听对象

wait方法在暂停执行的过程中会失去同步的监听对象,醒来后会重新拿到同步监听对象,再执行代码

死锁

所谓死锁就是锁死了,由于同步使用不当(一般是由于同步嵌套同步),产生的一种线程代码不执行的现象,JVM不会关闭,死锁一旦产生,无法挽救,只能事先避免,也称为线程的阻塞状态

共享的同步监听对象得不到释放,线程在互相等待资源释放,造成死锁的现象

示例代码:

    final String a = "333";
    final String b = "555";
    new Thread(){
        @Override
        public void run() {
           while (true){
                synchronized (a){
                    System.out.println("thread1:11");
                    synchronized (b){
                        System.out.println("thread1:22");
                    }
                }
           }
        }
    }.start();

    new Thread(){
        @Override
        public void run() {
            while (true){
                synchronized (b){
                    System.out.println("thread2:1111");
                    synchronized (a){
                        System.out.println("thread2:2222");
                    }
                }
            }
        }
    }.start();

执行结果:
I/System.out: thread2:1111
I/System.out: thread2:2222
I/System.out: thread2:1111
I/System.out: thread1:11  //此时a对象被t1占用,t2等待t1释放a,t1等待t2释放b,死锁

要想不发生死锁,建议使用Lock对象来完成同步

线程生命周期

  • 1:新建状态(new):使用new创建一个线程对象
  • 2:可运行状态(runnable):分成两种状态,ready和running。分别表示就绪状态和运行状态。

      start启动线程后,线程处于就绪的状态,要等CPU调用线程时才是运行状态
    
  • 3:阻塞状态(blocked):正在运行的线程遇到某个特殊情况如,同步、等待I/O操作完成等就会进入阻塞状态

  • 4:等待状态(waiting):

      当前线程等待另外一个线程执行某个操作,此时当前线程处于等待状态
      wait --->notifyAll
    
  • 5:计时等待状态(timed waiting) :

      当前线程等待另外一个线程来执行有指定等待时间的的操作,此时当前线程处于计时等待状态
    
      可运行的状态下调用sleep、wait方法,就会进入等待状态
    
      sleep是由线程来调用了,等待时不会失去监听对象
    
      wait是由同步监听对象调用的,等待时会失去监听对象
    
      可以调用notify、notifyAll方法回到可运行状态上,等待的时间到了,也会回到可运行的状态
    
  • 6:终止状态(terminated):通常称为死亡状态,表示线程终止

      getState()  返回该线程的状态
    

联合线程

线程的join()方法表示一个线程等待另一个线程完成后才执行。

有人也把这种方式称为联合线程,就是说把当前线程和当前线程所在的线程联合成一个线程。

  • join()方法被调用之后,线程对象处于等待状态
  • join()方法是在线程开启start()后再调用的
  • 线程1.join 表示在原来的线程A中插入线程1,需要等线程1执行完毕才继续执行原线程A

后台线程

在后台运行,其目的是为其他线程提供服务,也称为"守护线程"。JVM的垃圾回收器就是典型的后台线程。

特点:

若所有的前台线程都死亡,后台线程自动死亡。

thread.isDaemon() ---> 测试后台线程的方法
thread.setDaemon(true) ---> 设置后台线程的方法  线程创建默认是前台线程,
该方法必须在start()前调用,否则出现IllegalThreadStateException异常.

线程优先级

  • 每个线程都有优先级,优先级的高低只和线程获得执行机会的次数多少有关,并非线程优先级越高的就一定先执行,哪个线程的先运行取决于CPU的调度。
  • setPriority() 默认是5,最高是10,最低是1

    这个设置一般都不去手动设置,默认即可,因为不是所有系统都认识1-10中的优先级,但是1-5-10这3个优先级所有系统都认识

线程局部

ThreadLocal和线程同步机制

ThreadLocal和线程同步机制都是为了解决多线程中相同变量的访问冲突问题。
  • 在同步机制中,通过对象的锁机制保证同一时间只有一个线程访问变量。这时该变量是多个线程共享的,使用同步机制要求程序慎密地分析什么时候对变量进行读写,什么时候需要锁定某个对象,什么时候释放对象锁等繁杂的问题,程序设计和编写难度相对较大。
  • ThreadLocal

    从另一个角度来解决多线程的并发访问。ThreadLocal会为每一个线程提供一个独立的变量副本,从而隔离了多个线程对数据的访问冲突。因为每一个线程都拥有自己的变量副本,从而也就没有必要对该变量进行同步了。ThreadLocal提供了线程安全的共享对象,在编写多线程代码时,可以把不安全的变量封装进ThreadLocal。

    • 定义

      ThreadLocal是一个线程内部数据存储类,通过他可以在指定的线程中存储数据。存储后,只能在指定的线程中获取到存储的数据,对其他线程来说无法获取到数据。

    • 使用场景

      日常使用场景不多,当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,可以考虑使用ThreadLocal。

      Android源码的Lopper、ActivityThread以及AMS中都用到了ThreadLocal。

    • 示例代码

        public class ThreadLocalActivity extends AppCompatActivity {
        private ThreadLocal<String> name = new ThreadLocal<>();
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_thread_local);
            name.set("小明");
            Log.d("ThreadLocalActivity", "Thread:" + Thread.currentThread().getName() + " name:" + name.get());
            new Thread("thread1") {
                @Override
                public void run() {
                    name.set("小红");
                    Log.d("ThreadLocalActivity", "Thread:" + Thread.currentThread().getName() + " name:" + name.get());
                }
            }.start();
            new Thread("thread2") {
                @Override
                public void run() {
                    Log.d("ThreadLocalActivity", "Thread:" + Thread.currentThread().getName() + " name:" + name.get());
                }
            }.start();
        }
        }
      
      
        输出:
      
        D/ThreadLocalActivity: Thread:main name:小明  
        D/ThreadLocalActivity: Thread:thread1 name:小红  
        D/ThreadLocalActivity: Thread:thread2 name:null
      
        可以看到虽然访问的是同一个ThreadLocal对象,但是获取到的值却是不一样的
      
    • 源码分析结果

      根据源码分析得知,每个线程Thread中都有一个ThreadLocalMap类型的threadLocals成员变量来保存数据,通过ThreadLocal类来进行维护。

      我们每次在不同线程调用ThreadLocal的set方法set的数据是存在不同线程的ThreadLocalMap中的,就像注释说的ThreadLocal只是起了个维护ThreadLocalMap的功能。想到是get方法同样也是到不同线程的ThreadLocalMap去取数据。

    • ThreadLocal内存泄漏的原因

      ThreadLocalMap中存储数据的内部类Entry

         static class Entry extends WeakReference<ThreadLocal<?>> {
             /** The value associated with this ThreadLocal. */
             Object value;
             
             Entry(ThreadLocal<?> k, Object v) {
                 super(k);
                 value = v;
             }
         }
      
      • Entry这里的key是使用了个弱引用,所以因为使用弱引用这里的key,ThreadLocal会在JVM下次GC回收时候被回收,而造成了个key为null的情况,而外部ThreadLocalMap是没办法通过null key来找到对应value的。如果当前线程一直在运行,那么线程中的ThreadLocalMap也就一直存在,而map中却存在key已经被回收为null对应的Entry和value却一直存在不会被回收,造成内存的泄漏。

      设计者使用弱引用是由原因的:

      • 如果使用强引用,那么如果在运行的线程中ThreadLocal对象已经被回收了但是ThreadLocalMap还持有ThreadLocal的强引用,若是没有手动删除,ThreadLocal不会被回收,同样导致内存泄漏。

      • 如果使用弱引用ThreadLocal的对象被回收了,因为ThreadLocalMap持有的是ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。nullkey的value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

      ThreadLocal的内存内泄漏的真正原因并不能说是因为ThreadLocalMap的key使用了弱引用,而是因为ThreadLocalMap和线程Thread的生命周期一样长,没有手动删除Map的中的key才会导致内存泄漏。所以解决ThreadLocal的内存泄漏问题就要每次使用完ThreadLocal,都要记得调用它的remove()方法来清除。

线程池

Executor

定时器

Timer:

一种工具,用其安排在后台线程中执行任务,可安排执行一次,也可以定期重复执行.

Timer timer =new Timer();
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("1000毫秒后执行了任务");
        }
    },1000);
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345