继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

深入分析Synchronized原理

慕姐8265434
关注TA
已关注
手记 1275
粉丝 222
获赞 1065

1、Synchronized关键字

鉴于七牛云图片都挂了,这里给出自己服务器的地址:

访问这里就行了:
深入分析Synchronized原理

  • synchronized关键字是Java并发编程中非常重要的一个工具。它的主要目的是在同一时间只能允许一个线程去访问一段特定的代码,从而保护一些变量或者数据不会被其他线程所修改。这感觉就像一群人抢着去上厕所,而你运气好抢到了,啪把门一锁,厕所的那一平方天地在那段时间就只属于你,即使门外的人排队都排到了地中海(此处排除有人暴力拆厕所的情况)。

  • Synchronized的作用主要有三个:

    • 原子性:确保线程互斥的访问同步代码;

    • 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;

    • 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”;

从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在HotSpot JVM实现中,锁有个专门的名字:对象监视器(Object Monitor)。

  • 使用synchronized关键字后,都以对象作为锁,一般有以下三种实现形式。

    [图片上传失败...(image-d9d5c0-1544273864340)]

    [图片上传失败...(image-a7a42c-1544273864340)]

    • 3、对于同步代码块,锁是synchronized关键字括号内的对象。当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);

      [图片上传失败...(image-a53fb2-1544273864340)]

    • 2、对于静态同步方法,锁是当前类的Class对象。 当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;

    • 1、对于同步方法,锁是当前实例对象。

当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例.

  • 注意,synchronized 内置锁 是一种 对象锁(锁的是对象而非引用变量),作用粒度是对象,可以用来实现对 临界资源的同步互斥访问 ,是 可重入 的。其可重入最大的作用是避免死锁,如:

    • 子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁;

2、同步原理

  • 数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM,而j.u.c.Lock给出的答案是在硬件层面依赖特殊的CPU指令。

  • 当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:

    [图片上传失败...(image-d10e58-1544273864340)]

    执行 javap SynchronizedDemo.class 字节码文件

    [图片上传失败...(image-8a237d-1544273864340)]

  • monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

    • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;

    • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;

    • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

  • monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

  • monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

  • 通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

[图片上传失败...(image-48e1d2-1544273864340)]

javap -v  SynchronizedDemo.class

[图片上传失败...(image-3b7bd6-1544273864340)]

  • 从编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:

    • 当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

    • 两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

3、什么是对象头

[图片上传失败...(image-419b9c-1544273864340)]

  • 在JVM中,对象在内存中的布局分为3块:对象头、实例数据和对齐填充。先说说实例数据,它存储着对象真正的有效信息(程序代码中定义的各种类型的字段内容),无论是从父类继承来的字段还是子类中定义的。然后再是对齐填充,它并没有什么特殊的含义,仅仅只是起占位符的作用。原因呢是因为JVM要求对象的起始地址必须是8个字节的整数倍(对象的大小必须是8个字节的整数倍)。而对象头已经是8的整数倍了,如果实例数据没有对齐就需要对齐填充来补全。

    [图片上传失败...(image-efb4be-1544273864340)]

  • 重点来了,synchronized使用的锁都放在对象头里,JVM中用2个字宽来储存对象头(如果对象是数组则分配3个字宽,多的一个字宽用于存储数组的长度)。而对象头包含两部分信息,分别为Mark Word和类型指针。Mark Word主要用于储存对象自身的运行时数据,例如对象的hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等。而类型指针用于标识JVM通过这个指针来确定这个对象是哪个类的实例。下图是Java对象头 无锁状态下Mark Word部分的存储结构(32位虚拟机):

    [图片上传失败...(image-9b566c-1544273864340)]

  • 由于对象需要储存的运行时数据过多,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储更多的信息。对象在不同的状态下,Mark Word会存储不同的内容(只放32位虚拟机的图表)。

[图片上传失败...(image-29e064-1544273864340)]

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

[图片上传失败...(image-d3a74a-1544273864340)]

  • 对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级则存储指向线程栈中锁记录的指针。从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。

    [图片上传失败...(image-b0e406-1544273864340)]

3、对象头中Mark Word与线程中Lock Record

  • 在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝至关重要。

  • Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。如下图所示为Lock Record的内部结构:

    [图片上传失败...(image-283857-1544273864340)]

    • Lock Record 描述 Owner 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;

    • EntryQ 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程;

    • RcThis 表示blocked或waiting在该monitor record上的所有线程的个数;

    • Nest 用来实现 重入锁的计数;

    • HashCode 保存从对象头拷贝过来的HashCode值(可能还包含GC age)。

    • Candidate 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

4、监视器(Monitor)

  • 任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

  • MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;

  • MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;

  • 那什么是Monitor?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被描述为一个对象。

  • 与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。而每一个对象都存在着一个monitor与之关联。在Hot Spot中,monitor是由ObjectMonitor类来实现的。先来看一ObjectMonitor的数据结构。

[图片上传失败...(image-ad890e-1544273864340)]

  • ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:

    • 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;

    • 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;

    • 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);

    • 同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。

监视器Monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。

什么时候需要协作? 比如:

  • 一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。
    [图片上传失败...(image-43c1e7-1544273864340)]

  • 如上图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的Owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。

  • 注意:

    • 当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态。

5、Monitor指令

  • JVM通过进入和退出monitor对象来实现方法和代码块的同步,但是实现细节不一。可以使用javap -verbose XXX.class命令看代码被编译成字节码后是如何实现同步的。

    [图片上传失败...(image-7ad32a-1544273864340)]

  • 将含有synchronized代码块的代码反编译后,可以看到monitorenter和monitorexit两条指令。monitorenter处于代码块开始的位置,而monitorenter与之匹配在代码结束或者异常处。任何对象都有个monitor与之对应,当monitor被持有后,它就处于锁定状态。线程执行到monitorenter指令时,会尝试去获得对象的锁(即monitor的所有权)。

[图片上传失败...(image-e38bc2-1544273864340)]

  • 方法的同步是隐式的,JVM中使用 method_info 型数据中方法访问标志的      ACC_SYNCHRONIZED做区分。当线程执行代码时若发现方法的访问标志中有ACC_SYNCHRONIZED,则当前线程持有monitor对象。接下来执行的细节与同步代码块无异。以上便是synchronized关键字修饰的同步方法和同步代码块实现的基本原理了。

6、 锁的优化

  • 从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

  • 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

  • 在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

6.1、自旋锁

  • 线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。

  • 所以引入自旋锁。所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

  • 自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

  • 自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

  • 如果通过参数-XX:PreBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁),是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

6.2、适应性自旋锁

  • JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?

  • 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

  • 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

6.3、偏向锁

  • 偏向锁是JDK6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。

  • 偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。

  • 在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。如果并发数较大同时同步代码块执行时间较长,则被多个线程同时访问的概率就很大,就可以使用参数-XX:-UseBiasedLocking来禁止偏向锁(但这是个JVM参数,不能针对某个对象锁来单独设置)。

  • 引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。

  • 轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

那么偏向锁是如何来减少不必要的CAS操作呢?首先我们看下无竞争下锁存在什么问题:

  • 现在几乎所有的锁都是可重入的,即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是 一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。

  • CAS为什么会引入本地延迟?这要从SMP(对称多处理器)架构说起,下图大概表明了SMP的结构:

[图片上传失败...(image-6b9e7d-1544273864340)]

  • 其意思是 所有的CPU会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于BUS对称分布,因此这种结构称为“对称多处理器”。

  • 而CAS的全称为Compare-And-Swap,是一条CPU的原子指令,其作用是让CPU比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。

  • 例如:Core1和Core2可能会同时把主存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时,会通过总线,使Core2中L1 Cache对应的值“失效”,而Core2一旦发现自己L1 Cache中的值失效(称为Cache命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。

  • 而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

  • 所以,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:

    [图片上传失败...(image-2598d7-1544273864340)]

    偏向锁的释放采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要 等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

    • 暂停拥有偏向锁的线程;

    • 1、检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;

    • 2、若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);

    • 3、如果测试线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);

    • 4、通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;

    • 5、执行同步代码块;

  - 判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;
  - 注意:此处将 当前线程挂起再恢复的过程中并没有发生锁的转移,仍然在当前线程手中,只是穿插了个 “



作者:AKyS佐毅
链接:https://www.jianshu.com/p/b92e27025785


打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP