synchronized关键字

多线程数据错误示例:叫号程序

/**
 * 实现Runnable接口
 * @author Chenzf
 */

public class TicketWindowRunnable implements Runnable {
    private int index = 1;
    private final static int MAX = 8;

    /**
     * 将Thread中run的代码逻辑抽取到Runnable的一个实现中
     */
    @Override
    public void run() {
        while (index <= MAX) {
            System.out.println(Thread.currentThread() + " 正在办理的业务号为 " + (index++));
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {

        final TicketWindowRunnable task = new TicketWindowRunnable();

        Thread thread1 = new Thread(task, "No.1");
        Thread thread2 = new Thread(task, "No.2");
        Thread thread3 = new Thread(task, "No.3");
        Thread thread4 = new Thread(task, "No.4");

        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

输出结果:

Thread[1,5,main] 正在办理的业务号为 3
Thread[4,5,main] 正在办理的业务号为 4
Thread[3,5,main] 正在办理的业务号为 2
Thread[2,5,main] 正在办理的业务号为 1
Thread[2,5,main] 正在办理的业务号为 5
Thread[4,5,main] 正在办理的业务号为 6
Thread[1,5,main] 正在办理的业务号为 7
Thread[3,5,main] 正在办理的业务号为 6
Thread[2,5,main] 正在办理的业务号为 8
Thread[4,5,main] 正在办理的业务号为 9
Thread[1,5,main] 正在办理的业务号为 10
Thread[3,5,main] 正在办理的业务号为 11

数据错误原因

@Override
public void run() {
    while (index <= MAX) {
        System.out.println(Thread.currentThread() + " 正在办理的业务号为 " + (index++));
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

号码重复出现

线程1执行index+1,然后CPU执行权落入线程2手里,由于线程1并没有给index赋予计算后的结果393,因此线程2执行index+1的结果仍然是393,所以会出现重复号码的情况。

号码超过了最大值

inde x = 499的时候,线程1和线程2都看到条件满足,线程2短暂停顿,线程1将index增加到了500,线程2恢复运行后又将500
增加到了501,此时就出现了超过最大值的情况。

synchronized应用方式

public class ThreeSync {
    private static final Object object = new Object();

    // 修饰【实例方法】,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
    public synchronized void normalSyncMethod(){
      // 临界区
      // 锁的是当前实例对象,通常指this
    }

    // 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
    public static synchronized void staticSyncMethod(){
      // 临界区
      // 锁的是当前类的Class对象,如ThreeSync.class
    }

    // 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
    public void syncBlockMethod(){
      synchronized (object){
        // 临界区
        // 锁的是synchronized括号内的对象
      }
    }
}

对象锁

对象锁也叫方法锁,是针对一个对象实例的,它只在该对象的某个内存位置声明一个标识该对象是否拥有锁,所有它只会锁住当前的对象,而并不会对其他对象实例的锁产生任何影响不同对象访问同一个被synchronized修饰的方法的时候,不会阻塞!

类声明后,可以new出来很多的实例对象。这时候,每个实例在JVM中都有自己的引用地址和堆内存空间,可以认为这些实例都是独立的个体在实例上加的锁,和其他的实例就没有关系,互不影响了

使用对象锁时,只有使用同一实例的线程才会受锁的影响,多个实例调用同一方法也不会受影响。

  • 锁住实体里的非静态变量
    非静态变量是实例自身变量,不会与其他实例共享,所以锁住实体内声明的非静态变量,可以实现对象锁锁住同一个变量的方法块,共享同一把锁
    private Object lock = new Object();
    /**
     * 锁住非静态变量
     */
    public void lockObjectField() throws InterruptedException {
        synchronized (lock) {
            // do something
        }
    }
    
  • 锁住this对象
    this指的是当前对象实例本身,所以,所有使用synchronized(this)方式的方法都共享同一把锁
    public void lockThis() {
        synchronized(this) {
            // do something
        }
    }
    
  • 直接锁非静态方法
    public synchronized void methodLock() {
        // do something
    }
    

类锁

类锁是加载类上的,而类信息是存在JVM方法区的,并且整个JVM只有一份,方法区又是所有线程共享的,所以类锁是所有线程共享的

  • 锁住类中的静态变量
    因为静态变量类信息一样也是存在方法区的,并且整个JVM只有一份,所以加在静态变量上可以达到类锁的目的。

    private static Onject lock = new Object();
    /**
     * 锁住静态变量
     */
    public void lockStaticObjectField() {
        synchronized(lock) {
            // do something
        }
    }
    
  • 直接在静态方法上加synchronized
    因为静态方法同样也是存在方法区的并且整个JVM只有一份,所以加在静态方法上可以达到类锁的目的。

    public static synchronized void methodLock() {
        // do something
    }
    
  • 锁住xxx.class:对当前类的.class属性加锁,可以实现类锁。

    public void lockClass() {
        synchronized(ClassLock.class) {
            // do something
        }
    }
    

修饰实例方法

所谓的实例对象锁就是用synchronized修饰实例对象中的实例方法

public class AccountSync implements Runnable {
    // 共享资源
    static int i = 0;

    public void normalIncrease() {
        i++;
    }

    /**
     * synchronized修饰实例方法
     * 当前线程的锁便是实例对象instance
     */
    public synchronized void SyncIncrease() {
        // 该操作不具备原子性
        i++;
    }

    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            // normalIncrease();  // i = 12793
            SyncIncrease();  // 20000
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建实例
        AccountSync instance = new AccountSync();

        Thread thread1 = new Thread(instance, "Thread1");
        Thread thread2 = new Thread(instance, "Thread2");

        thread1.start();
        thread2.start();

        // 可中断方法
        thread1.join();
        thread2.join();

        System.out.println(i);
    }
}
  • 由于i++操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成。如果在第一个线程读取旧值和写回新值期间,第二个线程读取i的值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全。

  • synchronized修饰的是实例方法increase,在这样的情况下,当前线程的锁便是实例对象instance

注意:

  • 当一个线程正在访问一个对象的synchronized实例方法,那么其他线程不能访问该对象的synchronized方法。因为一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的synchronized实例方法,但是其他线程还是可以访问该实例对象的其他非synchronized方法

  • 如果是一个线程A需要访问实例对象obj1的synchronized方法f1(当前对象锁是obj1),另一个线程B需要访问实例对象obj2的synchronized方法f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据不是共享的,那么线程安全是有保障的;如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了:

public class AccountSync implements Runnable {
    // 共享资源
    static int i = 0;
    
    /**
     * synchronized修饰实例方法
     */
    public synchronized void SyncIncrease() {
        // 该操作不具备原子性
        i++;
    }

    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            SyncIncrease();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建了两个实例对象
        Thread thread1 = new Thread(new AccountSync(), "Thread1");
        Thread thread2 = new Thread(new AccountSync(), "Thread2");

        thread1.start();
        thread2.start();

        // 可中断方法
        thread1.join();
        thread2.join();

        System.out.println(i);  // 19917
    }
}

修饰静态方法

上述代码犯了严重的错误,虽然使用synchronized修饰了increase方法,但却new两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此Thread1Thread2都会进入各自的对象锁,也就是说Thread1和Thread2线程使用的是不同的锁,因此线程安全是无法保证的。

解决这种困境的的方式是synchronized作用于静态的increase方法对象锁就当前类对象,由于无论创建多少个实例对象,但相对应的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的

synchronized作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁,可以控制静态成员的并发操作

需要注意的是如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象。因为访问静态synchronized方法占用的锁是当前类的class对象,而访问非静态synchronized方法占用的锁是当前实例对象锁

public class AccountSync implements Runnable {
    // 共享资源
    static int i = 0;

    /**
     * synchronized作用于静态方法,锁是当前class对象,
     * 也就是AccountSync类对应的class对象
     */
    public static synchronized void SyncIncrease() {
        // 该操作不具备原子性
        i++;
    }

    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            SyncIncrease();  // 20000
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // AccountSync instance = new AccountSync();

        Thread thread1 = new Thread(new AccountSync(), "Thread1");
        Thread thread2 = new Thread(new AccountSync(), "Thread2");

        thread1.start();
        thread2.start();

        // 可中断方法
        thread1.join();
        thread2.join();

        System.out.println(i);
    }
}

同步代码块

  • 可以使用synchronized(this)格式来同步代码块;
  • Java还支持对任意对象作为对象监视器来实现同步的功能。这个任意对象大多数是实例变量方法的参数,使用格式为synchronized(非this对象)

在某些情况下,编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了

public class AccountSync implements Runnable {
    // 共享资源
    static int i = 0;
    static AccountSync instance = new AccountSync();

    @Override
    public void run() {
        /*
         *  省略其他耗时操作....
         *  使用同步代码块对变量i进行同步操作,锁对象为instance
         */
        synchronized (instance) {
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(instance,"Thread1");
        Thread thread2 = new Thread(instance, "Thread2");

        thread1.start();
        thread2.start();

        // 可中断方法
        thread1.join();
        thread2.join();

        System.out.println(i);  // 20000
    }
}

从代码看出,synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时,就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作

当然除了instance作为对象外,还可以使用this对象(代表当前实例)或者当前类的class对象作为锁:

public class AccountSync implements Runnable {
    // 共享资源
    static int i = 0;

    @Override
    public void run() {
        /*
         *  当前实例对象锁
         */
        synchronized (this) {
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        }
        /*
         *  当前类对象锁
         */
        synchronized (AccountSync.class) {
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        AccountSync instance = new AccountSync();

        Thread thread1 = new Thread(instance,"Thread1");
        Thread thread2 = new Thread(instance, "Thread2");

        thread1.start();
        thread2.start();

        // 可中断方法
        thread1.join();
        thread2.join();

        System.out.println(i);  // 20000
    }
}

如果在同一个类中有很多个synchronized方法,这是虽然能实现同步,但会受到阻塞,所以影响运行效率;但如果使用同步代码块锁非this对象,则synchronized(非this对象)代码块中的程序与同步方法是异步的,不与其他锁this同步方法争抢this锁,即可大大提高运行效率。

synchronized实现原理

public class SynchronizedDemo {
     //同步方法
    public synchronized void doSth(){
        System.out.println("Hello World");
    }

    //同步代码块
    public void doSth1(){
        synchronized (SynchronizedDemo.class){
            System.out.println("Hello World");
        }
    }
}

对上面的代码进行反编译

javac SynchronizedDemo.java
javap -c SynchronizedDemo.class

可以得到如下代码:

public synchronized void doSth();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2 // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc         #3 // String Hello World
         5: invokevirtual  #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

  public void doSth1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #5 // class com/hollis/SynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter       // monitorenter 进入同步方法
         5: getstatic       #2 // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #3 // String Hello World
        10: invokevirtual    #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit       // monitorexit 退出同步方法
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit			// monitorexit 退出同步方法
        21: aload_2
        22: athrow
        23: return

过反编译后代码可以看出:

  • 对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。
    方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁这时如果其他线程来请求执行方法,会因为无法获得监视器锁,而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前,监视器锁会被自动释放

  • 对于同步代码块,JVM采用monitorentermonitorexit两个指令来实现同步。可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁

    • monitorenter:每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
      1、如果monitor的进入计数器为0(entry count),则该线程进入monitor,然后将进入计数器设置为1,该线程即为monitor的所有者。
      2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入计数器加1。
      3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入计数器为0,再重新尝试获取monitor的所有权。

    • monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。

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

无论是ACC_SYNCHRONIZED还是monitorentermonitorexit都是基于Monitor实现的,在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现。

ObjectMonitor类中提供了几个方法,如enterexitwaitnotifynotifyAll等。sychronized加锁的时候,会调用objectMonitorenter方法,解锁的时候会调用exit方法。

synchronized保证可见性

Java内存模型规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中,保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。

synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。为了保证可见性规定:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。

所以,synchronized关键字锁住的对象,其值是具有可见性的。

synchronized保证原子性

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorentermonitorexit。这两个字节码指令,在Java中对应的关键字就是synchronized。通过monitorentermonitorexit指令,可以保证synchronized修饰的代码,在同一时间,只能被一个线程访问,在锁未释放之前,无法被其他线程访问到

线程1在执行monitorenter指令的时候,会对Monitor进行加锁加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是,由于线程1并没有进行解锁,而synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。

因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的

synchronized保证有序性

除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save有可能被优化成load->save->add。这就是可能存在有序性问题。

synchronized无法禁止指令重排和处理器优化的,但由于synchronized修饰的代码,同一时间只能被同一线程访问,也就是单线程执行的。所以,可以保证其有序性。

synchronized锁优化

synchronized其实是借助Monitor实现的,在加锁时会调用objectMonitorenter方法,解锁的时候会调用exit方法。事实上,只有在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitorenterexit,这种锁被称之为重量级锁

所以,在JDK1.6中出现对锁进行了很多的优化,进而出现偏向锁轻量级锁适应性自旋锁锁消除(去除不可能存在竞争的锁),锁粗化(扩大锁的范围,避免反复加锁和释放锁)(自旋锁在1.4就有,只不过默认的是关闭的,jdk1.6是默认开启的),这些操作都是为了在线程之间更高效的共享数据,解决竞争问题。

偏向锁

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁

但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁。需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁

轻量级锁

锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能

轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时,需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁

虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,升级为重量级锁

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

下面代码中,StringBufferappend是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除:

/**
 * 消除StringBuffer同步锁
 */
public class StringBufferRemoveSync {

    public void add(String str1, String str2) {
        // StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
        // 因此sb属于不可能共享的资源,JVM会自动消除内部的锁
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }

    public static void main(String[] args) {
        StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
        for (int i = 0; i < 10000000; i++) {
            rmsync.add("abc", "123");
        }
    }
}

synchronized关键字的缺陷

synchronized关键字提供了一种排他式的数据同步机制,某个线程在获取monitor lock的时候可能会被阻塞,而这种阻塞有两个很明显的缺陷

  • 第一,无法控制阻塞时长

    thread2启动执行syncMethod方法时会进入阻塞,thread2什么时候能够获得syncMethod的执行,完全取决于thread1何时对其释放。如果thread2计划最多1分钟获得执行权,否则就放弃,很显然这种方式是做不到的,这也就是说阻塞时长无法控制!

  • 第二,阻塞不可被中断

    虽然可以设置thread2线程的interrupt标识,但是synchronized阻塞不像sleep和wait方法一样能够捕获得到中断信号,因此thread2若因争抢某个monitor的锁而进入阻塞状态,那么它是无法中断的

  • 第三,在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。

package communication;

import java.util.concurrent.TimeUnit;

public class SynchronizedDefect {

    public synchronized void synMethod () {
        try {
            TimeUnit.MINUTES.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建实例
        SynchronizedDefect defect = new SynchronizedDefect();
        // 创建线程
        Thread thread1 = new Thread(defect::synMethod, "Thread1");
        thread1.start();
        // 确保Thread1能先获得锁
        TimeUnit.SECONDS.sleep(1);
        System.out.println("Thread1:" + thread1.getState());
        
        Thread thread2 = new Thread(defect::synMethod, "Thread2");
        thread2.start();
        
        System.out.println("Thread2:" + thread2.getState());
        // Thread2因争抢monitor的锁而进入阻塞状态,无法中断
        TimeUnit.SECONDS.sleep(1);
        System.out.println("Thread2:" + thread2.getState());
        thread2.interrupt();
        System.out.println("Thread2是否被中断:" + thread2.isInterrupted());
        System.out.println("Thread2:" + thread2.getState());
    }
}

输出结果:

Thread1:TIMED_WAITING  // 进入睡眠TimeUnit.MINUTES.sleep(10);
Thread2:RUNNABLE
Thread2:BLOCKED
Thread2是否被中断:true
Thread2:BLOCKED
// 等待
posted @ 2021-03-14 16:41  chenzufeng  阅读(49)  评论(0编辑  收藏  举报