Loading

Java NIO中的Buffer类

Buffer类

当应用程序进行数据传输的时候,往往需要使用缓冲区,常用的缓存区就是JDK NIO类库提供的 java.nio.Buffer

NIO的Buffer本质上是一个内存块,既可以写入数据,也可以从中读取数据;

其中,Java NIO中代表缓冲区的Buffer类是一个抽象类,对应于Java的主要数据类型,在NIO中有8种缓存区,分别如下:ByteBuffer,CharBuffer,DoubleBuffer,FloatBuffer,IntBuffer,LongBuffer,ShortBuffer,MappedByteBuffer;前7种 Buffer类型,覆盖了能在 IO中传输的所有的 Java基本数据类型,第8种类型MappedByteBuffer是专门用于内存映射的一种 ByteBuffer类型;

NIO的Buffer的内部是一个内存块(数组),此类用来与普通的内存块(Java数组)不同的是:Buffer对象提供了一组比较有效的方法,用来进行写入和读取的交替访问;

注:Buffer类是一个非线程安全类;

 

ByteBuffer

ByteBuffer的局限性

实际上,7种基础类型(Boolean除外)都有自己的缓冲区实现,对于NIO编程而言,应用程序中主要使用的是 ByteBuffer;从功能角度而言,ByteBuffer 完全可以满足NIO编程的需要,但是对于NIO编程的复杂性,ByteBuffer也具有局限性,它的缺点如下:

  • ByteBuffer 长度固定,一旦分配完成,它的容量不能动态扩展和收缩,当需要编码的POJO对象大于ByteBuffer的容量时,会发生索引越界;

  • ByteBuffer 只有一个标识位置的指针 position,读写的时候需要手工调用flip()和rewind()等;

  • ByteBuffer 的API功能有限,一些高级和实用的特性它不支持,需要使用者自己实现;

JDK ByteBuffer由于只有一个位置指针用于处理读写操作,因此每次读写的时候都需要额外调用Buffer#flip和Buffer#clear等方法,否则功能将会出错;

 

关于ByteBuffer在JVM内存与堆外外内存比较

java.nio.ByteBuffer#allocate: 分配空间位于JVM中(也称JVM堆内存),分配空间需要从外界Java程序接收到外部传来的数据时,首先被系统内存获取,然后再由系统内存复制拷贝到JVM内存中供Java程序使用;

java.nio.ByteBuffer#allocateDirect:  分配的内存是系统内存(也称直接内存),无需复制

 

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,某些情况下这部分内存也会被频繁地使用,而且也可能导致OutOfMemoryError异常出现;Java里用DirectByteBuffer可以分配一块直接内存(堆外内存),元空间对应的内存也叫作直接内存,它们对应的都是机器的物理内存

参考:[《深入理解 Java 虚拟机 第三版》2.2.7 小节]

堆外内存与对内内存对比

  • 对内内存有JVM GC自动回收内存,降低了Java用户的使用心智,但是GC是需要时间开销成本的,对外内存由于不受JVM管理,所以在一定程度上可以降低GC对应用运行时带来的影响;
  • 堆外内存需要手动释放,这一点跟C/C++很像,稍有不慎就会造成程序内存泄漏,当出现内存泄漏问题时排查起来会相对困难;
  • 当进行网络I/O操作,文件读写时,堆内内存都需要转换为堆外内存,然后再与底层设备进行交互,以下图传统Java程序(堆内存Socket IO)流程中数据拷贝为例;

堆外内存可以减少一次内存拷贝,即较少一次CPU复制,以下图Java NIO(DirectBuffer的Socket IO)读写流程为例;

堆外内存的分配

Java中的堆外内存的分配方式有两种:ByteBuffer#allocateDirectUnsafe#allocateMemoryUnsafe#allocateMemory和ByteBuffer#allocateDirect都用于分配直接内存,但是它们的行为略有不同;

ByteBuffer#allocateDirect分配

ByteBuffer#allocateDirect会直接调用DirectByteBuffer构造方法;

DirectByteBuffer构造方法

在堆内存放的DirectByteBuffer对象并不大,仅包含堆外内存的地址,大小等属性,同时还会创建对应的Cleaner对象,通过ByteBuffer分配的堆外内存不需要手动回收,它可以被JVM自动回收;当堆内的DirectByteBuffer对象被GC回收时,Cleaner就会用于回收对应的堆外内存;

DirectByteBuffer如何自动回收?

假设有一种情况,DirectByteBuffer对象可能长时间存在于堆内内存中,那么它很大可能会晋升到老年代,这时候DirectByteBuffer对象回收需要依赖Old GC或Full GC才能触发清理,即使触发了GC清理了堆内存与栈之间的引用关系,但是分配的堆外内存Buffer还是存在,如下图;

使用DirectByteBuffer时,避免物理内存耗尽的方式如下:

  • 设置-XX:MaxDirectMemorySize在Java中,-XX:MaxDirectMemorySize参数用于限制直接内存的最大分配量)指定堆外内存的上线大小,当堆外内存的大小超过阈值时,就会触发一次Full GC进行清理回收,如果在Full GC后还是无法满足堆外内存的分配,那么应用程序就会抛出OOM异常;
  • 在ByteBuffer#allocateDirect分配过程中,如果没有足够的空间分配堆外内存,在Bits#reserveMemory方法中也会主动调用System.gc()强制执行Full GC,但是在生产环境一般是设置了-XX:+DisableExplicitGC,System.gc()是不起作用的;

 

在DirectByteBuffer创建时,同时为分配内存地址创建了一个Cleaner对象,而Cleaner是继承于PhantomReference,如下图;

PhantomReference是Java对象四种引用中的虚引用,PhantomReference不能被单独使用,需要与引用队列ReferenceQueue联合使用;

Cleaner对象中的thunk属性也是在Cleaner对象创建的时候以new Deallocator的形式传进去的;

sun.misc.Cleaner#Cleaner

thunk是一个Runable接口的参数,因此切入口是Deallocator实现的run方法;

java.nio.DirectByteBuffer.Deallocator#run

在实现的run方法中会调用unsafe.freeMemory释放堆外内存;

 

当初始化堆外内存时,Cleaner对象会在初始化是加入Cleaner链表中,DirectByteBuffer对象包含堆外内存的地址,大小以及Cleaner对象的引用,ReferenceQueue用于保存需要回收的Cleanner对象;

当发生GC时,DirectByteBuffer对象被回收;

此时Cleaner对象不再有任何引用关系,在下一次GC时,该Cleaner对象将被添加到ReferenceQueue中,并执行clean方法;

而clean方法主要有两个作用:

  • 将Cleaner对象从Cleaner链表中移除;
  • 调用unsafe.freeMemory方法清理堆外内存;

 

Unsafe#allocateMemory

Unsafe是一个非常不安全的类,它用于执行内存访问,分配,修改等敏感操作,可以越过JVM限制的枷锁;

注:

  • Unsafe最初并不是为了开发者设计的,使用它虽然可以获取底层资源的控制权,但也失去了安全性的保证;
  • Unsafe#allocateMemory是一个底层的方法,它直接使用Unsafe类来分配内存,绕过了Java内存管理系统;因此,它不受-XX:MaxDirectMemorySize参数的限制,无论设置了多大的最大直接内存分配量,Unsafe#allocateMemory都可以分配超过该限制的内存;

 

Netty中依赖了Unsafe工具类,是因为Netty需要与底层Socket进行交互,Unsafe在提升Netty的性能方面起到了一定帮助;

在Java中是不能直接使用Unsafe的,需要通过反射获取Unsafe实例的,示例如下:

private static Unsafe unsafe = null;
static {
    try {
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);
        unsafe = (Unsafe) theUnsafe.get(null);
    } catch (NoSuchFieldException | IllegalAccessException e) {
        e.printStackTrace();
    }
}

 

获取到Unsafe实例后,可以通过allocateMemory方法分配堆外内存,allocateMemory方法返回的是内存地址,示例如下:

// 分配10M堆外内存
long address = unsafe.allocateMemory(10 * 1024 * 1024);

与DirectByteBuffer不同的是,Unsafe#allocateMemory所分配的内存必须是自己动手释放的,否则会造成内存泄漏,这也是Unsafe不安全的体现,释放内存操作如下:

  • sun.misc.Unsafe#freeMemory

Buffer类的重要属性

Buffer的子类会拥有一块内存,作为数据的读写缓冲区,但是读写缓冲区并没有定义在Buffer基类,而是定义在具体的子类中;

为了记录读写的状态和位置,Buffer类额外提供了一些重要的属性,如下:

capacity

Buffer类的capacity属性,表示缓冲区中的最大数据容量;一旦写入的对象数量超过了capacity容量,缓冲区就满了,不能再写入,而且Buffer类的capacity属性一旦初始化,就不能更改,因为Buffer类的对象在初始化时,它会按照capacity分配内部数组的内存,在数组分配好内存之后,它的大小则不能更改,比如capacity为1024的IntBuffer,代表其一次可以存储1024个int类型的值;

注:capacity容量并不是指Buffer内部的内存块byte[]数组的字节数量,而是指能写入的数据对象最大限制量;如在ByteBuffer中内部的内存块存储在ByteBuffer#hb成员属性上,该数组的长度是可以扩容和压缩的;

 

position

Buffer类的position属性,表示当前的位置,即被写入或者读取的元素索引;position属性的值与缓冲区的读写模式有关,在不同的模式下,position属性值的含义是不同的,在缓存区进行读写的模式改变时,position值会进行相应的调整;

  • 写模式下的position值变化规则

在写模式下,position值变化规则如下:

  1. 在刚进入到写入模式时position值为0,表示当前的写入位置为从头开始;
  2. 每当一个数据写到缓冲区后,position会向后移动到下一个可写的位置;
  3. 初始的position值为0,最大可写值为limit - 1,当position值达到limit时,缓冲区就已经无空间可写了;

 

  • 读模式下的position值变化规则

在读模式下,position的值变化规则如下:

  • 当缓冲区刚开始进入到读取模式时,position会被重置为0;
  • 当从缓冲区读取时,从position位置开始读;读取数据后,position向后移动到下一个可读的位置;
  • 在读模式下,limit表示可以读上限;position的最大值,为最大可读上限limit,当position达到limit时,表明缓冲区已经无数据可读;

 

  • Buffer的读写模式切换

当新建了一个缓冲区实例时,缓冲区处于写入模式,这时是可以写数据的;在数据写入完成后,如果要从缓冲区读取数据,这就要进行模式的切换,可以使用(即调用) flip翻转方法,将缓冲区变成读取模式;

从写入模式到读取模式的flip方法翻转过程中, position和limit属性值会进行调整,规则如下:

  • limit属性被设置成写入模式时的position值,表示可以读取的最大数据位置
  • position由原来的写入位置,变成新的可读位置,即0的位置,表示可以从头开始读

 

limit

Buffer类的limit属性,表示还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读取缓冲区时),limit属性值的含义与缓冲区的读写模式有关;

  • 在写入模式下,limit属性值的含义为可以写入的数据最大上限;在刚进入到写入模式时, limit的值会被设置成缓冲区的capacity容量值,表示可以一直将缓冲区的容量写满;
  • 在读取模式下,limit属性值的含义为最多能从缓冲区读取多少数据;

 

  • limit值在读写模式下的取值
  1. 当新创建的缓冲区时,Buffer处于写入模式,其position值为0,limit值为最大容量capacity;
  2. 往缓冲区写入数据,每写一个数据,position向后偏移一个位置,即position值加1;
  3. 当调用flip方法时,将缓冲区切换到读模式,将写入模式下的position的值设置成读模式下的limit的值,即写入模式下的position的值为读模式下的limit的值;

 

  • java.nio.Buffer#flip

mark

标记着当前position可读或可写的一个备份值,可供后续恢复时使用;

在缓冲区操作(读取或写)的过程中,可以将当前的position的值,临时存入mark属性中;在需要恢复的时候,可以再从mark中取出之前的值,恢复到positioin属性中,后续可以重新从position为重开始处理(读取或写);

  • java.nio.Buffer#mark

 

Buffer类的重要方法

Buffer#allocate 创建缓冲区

如果需要获取一个Buffer实例对象,并不是使用子类的构造器来创建一个实例对象,而是调用子类的allocate方法;

查看代码
@Test
public void testAllocate() {
    IntBuffer intBuffer = null;
    intBuffer = IntBuffer.allocate(20);
    logger.info("------------after allocate------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
}

例子中,IntBuffer是具体的Buffer子类,通过调用IntBuffer.allocate(20),创建了一个Intbuffer实例对象,并且分配了 20 * 4个字节的内存空间;

执行结果如下:

mark,position,capactiy,limit关系图如下:

一个缓冲区在新建后,处于写入的模式,position属性的值为 0,缓冲区的capacity容量值也是初始化时 allocate方法的参数值,而limit最大可写上限值也为的allocate方法的初始化参数值;

 

Buffer#put 写入缓冲区

在调用allocate方法分配内存、返回了实例对象后,缓冲区实例对象处于写模式,可以写入对象,而如果要写入对象到 缓冲区,需要调用put方法;

查看代码
 @Test
public void testPut() {
    IntBuffer intBuffer = IntBuffer.allocate(20);
    logger.info("------------after allocate------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    for (int i = 0; i < 5; i++) {
        intBuffer.put(i);
    }

    logger.info("------------after putTest------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
}

执行结果如下:

mark,position,capactiy,limit关系图如下:

从结果可以看到,写入了5个元素之后,缓冲区的position属性值变成了5(从下标0开始),所以指向了第6个可以进行写入的元素位置,而 limit最大可写上限、capacity最大容量两个属性的值,都没有发生变化;

 

Buffer#flip 翻转

flip翻转方法是Buffer类提供的一个模式转变的重要方法,它的作用就是将写入模式翻转成读取模式;

查看代码
@Test
public void flipTest() {
    IntBuffer intBuffer = null;
    intBuffer = IntBuffer.allocate(20);
    logger.info("------------after allocate------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    for (int i = 0; i < 5; i++) {
        intBuffer.put(i);

    }

    logger.info("------------after putTest------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    intBuffer.flip();
    logger.info("------------after flipTest ------------------");

    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
}

执行结果如下:

mark,position,capactiy,limit关系图如下:

 

缓冲区从读取模式切换到写入模式

通过调用Buffer#clear清空或Buffer#compact压缩,它们可以将缓冲区转换为写入模式;Buffer模式转换如下:

Buffer#get 从缓冲区读取

调用flip方法将缓冲区切换成读取模式之后,就可以开始从缓冲区中进行数据读取,通过调用get方法每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整;

查看代码
@Test
public void getTest() {
    IntBuffer intBuffer = null;
    intBuffer = IntBuffer.allocate(20);
    logger.info("------------after allocate------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    for (int i = 0; i < 5; i++) {
        intBuffer.put(i);

    }

    logger.info("------------after putTest------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    intBuffer.flip();
    logger.info("------------after flipTest ------------------");

    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    for (int i = 0; i < 2; i++) {
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);
    }

    logger.info("------------after get 2 int ------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    for (int i = 0; i < 3; i++) {
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);
    }
    logger.info("------------after get 3 int ------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
}

执行结果如下:

读取操作会改变可读位置position的属性值,而limit可读上限值并不会改变;在position值和limit的值相等时,表示所有数据读取完成,position指向了一个没有数据的元素位置,已经不能再读了,此时再读,会抛出BufferUnderflowException异常;

mark,position,capactiy,limit关系图如下:

处于读取模式下,不能对缓冲区进行数据写入,需要调用Buffer#clear或Buffer#compact方法,即清空或压缩缓冲区,将缓冲区切换成写入模式,让缓冲区重新可写;

 

Buffer#rewind 倒带

已经读完的数据,如果需要再读一遍,可以调用rewind方法;

查看代码
@Test
public void getTest() {
    IntBuffer intBuffer = null;
    intBuffer = IntBuffer.allocate(20);
    logger.info("------------after allocate------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    for (int i = 0; i < 5; i++) {
        intBuffer.put(i);

    }

    logger.info("------------after putTest------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    intBuffer.flip();
    logger.info("------------after flipTest ------------------");

    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    for (int i = 0; i < 2; i++) {
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);
    }

    logger.info("------------after get 2 int ------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    for (int i = 0; i < 3; i++) {
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);
    }
    logger.info("------------after get 3 int ------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    intBuffer.rewind();
    logger.info("------------after rewind ------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
}

执行结果如下:

  • java.nio.Buffer#rewind

rewind方法,主要是调整了缓冲区的 position属性与 mark标记属性,调整规则如下:

  1. position重置为 0,所以可以重读缓冲区中的所有数据;
  2. limit保持不变,数据量还是一样的,仍然表示能从缓冲区中读取的元素数量;
  3. mark标记被清理,表示之前的临时位置不能再用了;

在调用rewind方法后,就可以再一次读取Buffer;

 

Buffer#mark和Buffer#reset

Buffer#mark方法和Buffer#reset方法是成套使用的,Buffer#mark方法将当前position的值保存起来,放在mark属性中,让mark属性记录这个临时位置,之后可以调用Buffer#reset方法将mark的值恢复到position中;

查看代码
@Test
public void resetTest() {
    IntBuffer intBuffer = IntBuffer.allocate(20);
    logger.info("------------after allocate------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    for (int i = 0; i < 5; i++) {
        intBuffer.put(i);

    }

    logger.info("------------after putTest------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    intBuffer.flip();
    logger.info("------------after flipTest ------------------");

    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    for (int i = 0; i < 5; i++) {
        if (i == 2) {
            intBuffer.mark();
        }
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);

    }
    logger.info("------------after mark------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    intBuffer.reset();
    logger.info("------------after reset------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
    for (int i = 2; i < 5; i++) {
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);
    }
}

执行结果如下:

  • java.nio.Buffer#mark

上面例子,在读到第3个元素时调用mark方法,把当前位置position的值保存到mark属性中,这时mark属性的值为 2;

Buffer#mark调用后,mark,position,capactiy,limit关系图如下:

  • java.nio.Buffer#reset

上面例子在调用reset方法后,把mark中的值恢复到position中,因此读取的位置position就是 2,表示可以再次开始从第3个元素开始读取数据;

Buffer#reset调用后,mark,position,capactiy,limit关系图如下:

 

Buffer#clear 清空缓冲区

在读取模式下,调用clear方法将缓冲区切换为写入模式;

查看代码
@Test
public void clearTest() {
    IntBuffer intBuffer = IntBuffer.allocate(20);
    logger.info("------------after allocate------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());


    for (int i = 0; i < 5; i++) {
        intBuffer.put(i);

    }

    logger.info("------------after putTest------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    intBuffer.flip();
    logger.info("------------after flipTest ------------------");

    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    for (int i = 0; i < 5; i++) {
        if (i == 2) {
            intBuffer.mark();
        }
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);

    }
    logger.info("------------after mark------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());

    intBuffer.reset();
    logger.info("------------after reset------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
    for (int i = 2; i < 5; i++) {
        int j = intBuffer.get();
        logger.info("intBuffer[" + i + "]:" + j);

    }

    intBuffer.clear();
    logger.info("------------after clear------------------");
    logger.info("position=" + intBuffer.position());
    logger.info("limit=" + intBuffer.limit());
    logger.info("capacity=" + intBuffer.capacity());
}

执行结果如下:

Buffer#clear调用后,mark,position,capactiy,limit关系图如下:

在缓冲区处于读取模式时,调用clear方法,缓冲区会被切换成写入模式,清空了position的值,其值被设置为0,并且limit值为最大容量(capacity);

 

  • java.nio.Buffer#clear

此方法的作用如下:

  • 将position属性清0;
  • limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满;
  • mark属性赋值为-1;
posted @ 2022-10-20 01:36  街头卖艺的肖邦  阅读(259)  评论(0编辑  收藏  举报