运行时数据区

运行时数据区

程序计数寄存器

1、在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

2、任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的VM指令地址;或者,如果是在执行native方法,则是未指定值(undefned) 。

3、它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

4、字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

5、它是唯一一个在Java虚拟机规范中没有规定任何outotMemoryError情况的区域。

虚拟机栈

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。

优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

每个线程在创建时都会创建一个虚拟机栈,其内部保存着一个个的栈帧(Stack Frame),对应的一次次的方法调用。

1、主管java程序的运行,它保存着方法的局部变量、部分结果,并参与方法的调用和返回。
1、栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

2、jvm对java栈的操作只有两个

①每个方法执行,伴随着进栈

②执行后的出栈

3、对于栈来说不存在垃圾回收问题
JAVA虚拟机允许JAVA栈的大小是动态的或者固定不变的

1、如果是固定的栈,如果现场请求分配的栈容量超过java栈允许的最大容量,虚拟机会抛出StackOverFlowError异常。

2、如果是动态的,线程无法申请到足够内容去创建虚拟机栈,虚拟机会抛出OutOfMemoryError异常。

1、不同线程中所包含的栈帧是不允许存在相互引用的。

2、如果当前方法调用了其他方法,方法返回的时候,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

3、java有两种返回函数的方式,一种是正常的函数返回,使用return。另一种是抛出异常,不管是哪种方式,都会导致栈帧被弹出。

 

内部结构
每个栈帧中存储着:局部变量表(Local Variables)
1、定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
2、由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。

3、局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

4、方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

5、局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

 

1、局部变量最基本的存储单元是slot(变量槽)。

2、局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

①byte . short . char在存储前被转换为int,boolean也被转换为int,0表示false ,非o表示true。

②long和double 则占据两个slot。

 

成员变量
1、类变量(静态变量):链接的准备阶段:给类变量默认赋值。另一次则是在初始化阶段,赋予程序员在代码中定义的初始值。

2、实例变量:随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值。

 

局部变量
在使用前,必须进行显示赋值!否则编译不通过

性能调优与垃圾回收
1、在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。

2、局部变量表中的变量也是重要的垃圾回收根节点(GC Roots),只要被局部变量表中直接或间接引用的对象都不会被回收。

2、操作数栈(Operand Stack)

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop)。

 

1、某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。

2、比如:执行复制、交换、求和等操作。

3、操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

4、如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

5、java虚拟机的解释引擎是基于栈的执行引擎

3、动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
invokevirtual、invoikeinterface
在编译器就确定了具体的调用版本,这个版本在运行时是不可改变的

 

静态方法、私有方法、final方法、实例构造器、父类方法

invokestatic、invokespecial

invokedynamic

动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

 

var name='tx';判断变量自身的类型信息。

 

String name="tx";

方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)

正常返回
在字节码指令中,返回指令包含ireturn (当返回值是boolean、byte、char、short和int类型时使用)、lreturn、 freturn、 dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
异常返回
在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在丕方江的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

 

本地方法栈

1、有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。

 

2、Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。

 

3、本地方法栈,也是线程私有的。

 

4、允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

 

①如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会

 

抛出一个stackoverflowError 异常。

 

②如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个outofMemoryError异常。

 

5、本地方法是使用c语言实现的。

 

6、它的具体做法是Native Method stack中登记native方法,在Execution Engine执行时加载本地方法库。

 

堆空间大小设置
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
起始内存
“-Xms"用于表示堆区的起始内存,等价于-XX:InitiaIHeapsize
默认情况下,初始内存大小:物理电脑内存大小/ 64

 

最大内存
“-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapsize
一旦堆区中的内存大小超过“-Xmx“所指定的最大内存时,将会抛OutOfMemoryError异常。
最大内存大小:物理电脑内存大小/4

 

分代

  1. new的对象先放伊甸园区。此区有大小限制。

2.当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。

3.然后将伊甸园中的剩余对象移动到幸存者0区。

4.如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。

5.如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

6.啥时候能去养老区呢?可以设置次数。默认是15次。

 

·可以设置参数: -XX:MaxTenuingThreshold=进行设置。

总结:①针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。

②关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。

 

年轻代
Eden
1、Eden区占年轻代的8/10。

2、几乎所有的Java对象都是在Eden区被new出来的。

3、绝大部分的Java对象的销毁都在新生代进行了。IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。

4、可以使用选项"-xmn"设置新生代最大内存大小这个参数一般使用默认值就可以了。

 

Survivor0
1、Survivor0区占年轻代的1/10。
Survivor1
1、Survivor1区占年轻代的1/10。

 

老年代
1、默认老年代占堆空间的2/3。

2、默认经历过15次垃圾回收仍然存活的对象会被移动到老年代。

 

为什么要把堆分代?
经研究,不同对象的生命周期不同,70%-99%的对象是临时对象。
1、新生代:有Eden、两块大小相同的survivor(又称为from/to,s0/s1)构成,to总为空。

2、老年代:存放新生代中经历多次GC仍然存活的对象。

 

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
Minor GC
1、只是年轻代的垃圾收集。

2、标记-复制-清除算法,不会产生内存碎片,采用指针碰撞的方式分配新对象。

3、当年轻代空间不足时,就会触发Minor Gc,这里的年轻代满指的是Eden代满,survivor满不会引发Gc。(每次Minor Gc会清理年轻代的内存。

4、因为Java 对象大多都具备朝生夕灭的特性,所以 Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。

5、Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

 

Major GC
1、只是老年代的垃圾收集。

2、采用标记清除或标记清除整理算法,前一种会维护一个空闲列表,后一种则不会。

3、指发生在老年代的Gc,对象从老年代消失时,我们说“Major Gc”或“Full Gc发生了。

4、出现了Major Gc,经常会伴随至少一次的Minor Gc(但非绝对的,在Parallelscavenge收集器的收集策略里就有直接进行Major Gc的策略选择过程)。
①也就是在老年代空间不足时,会先尝试触发Ninor Gc。如果之后空间还不足,则触发Major Gc。

5、Major Gc的速度一般会比Minor c慢10倍以上,STW的时间更长。

6、如果Major Gc 后,内存还不足,就报OOM了。

 

Full GC
1、调用system.gc()时,系统建议执行Full Gc,但是不必然执行

2、老年代空间不足

3、方法区空间不足

4、通过Minor Gc后进入老年代的平均大小大于老年代的可用内存。

5、由Eden区、survivor space0 (From Space)区向survivor space1 (ToSpace)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明: full gc是开发或调优中尽量要避免的。这样暂时时间会短一些。

 

总结
1、年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。

2、老年代放置长生命周期的对象,通常都是从survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代.。

3、当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGC。当Gc发生在老年代时则被称为MajorGC或者FullGc。一般的,MinorGC 的发生频率要比MajorGc高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。

 

方法区

《Java虚拟机规范》中明确说明:"尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

所以,方法区看作是一块独立于Java堆的内存空间。
1、方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

2、方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。

3、方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

4、方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java.lang.outofMemoryError:PermGen space 或者java.lang.outOfMemoryError: Metaspace

5、关闭JVM就会释放这个区域的内存。

 

永久代为什么被元空间替代
随着Java8 的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace ) 。

 

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

这项改动是很有必要的,原因有:

1、为永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

2、对永久代进行调优是很困难的。

 

方法区内存设置
默认值依赖于平台。windows下,-XX:Metaspacesize是21M,-XX:MaxMetaspacesize 的值是-1,即没有限制。

1、与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常outofMemoryError: Metaspace。

2、-XX:Metaspacesize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-xx:Metaspacesize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspacesize时,适当提高该值。如果释放空间过多,则适当降低该值。

3、如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到FullGC多次调用。为了避免频繁地GC ,建议将-XX:Metaspacesize设置为一个相对较高的值。

 

OOM解决方法
1、要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)还是内存溢出(Memory overflow) 。

2、如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。

3、如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

 

存储
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

1、这个类型的完整有效名称(全名=包名.类名)。

2、这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)。

3、这个类型的修饰符(public,abstract,final的某个子集)。

4、这个类型直接接口的一个有序列表。

 

方法信息
1、方法名称

2、方法的返回类型(或void)

3、方法参数的数量和类型(按顺序)

4、方法的修饰符(public, private, protected,static, final,synchronized, native, abstract的一个子集)

5、方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

6、异常表(abstract和native方法除外)

每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、

被捕获的异常类的常量池索引

 

运行时常量池
为什么需要常量池
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。

 

常量池中有什么
数量值
字符串值
类引用
字段引用
方法引用

 

概念
常量池表是文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

1、运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。

2、JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

3、运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。运行时常量池,相对于class文件常量池的另一重要特征是:具备动态性。

4、运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。

5、当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outofMemoryError异常。

 

垃圾回收
方法区中的垃圾回收主要回收两个部分的内容:

1、常量池中的废弃常量
常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

1、类和接口的全限定名

2、字段的名称和描述符

3、方法的名称和描述符

 

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

回收废弃常量与回收Java堆中的对象非常类似。

 

2、不再使用的类型
1、该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

2、加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、 SP的重加载等,否则通常是很难达成的。

 

3、该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

 

对象实例化
创建对象的方式
new
XXX的静态方法
XxxBuilder/XxxFactory的静态方法
Class的newInstance()
反射的方式,只能调用空参的构造器,权限必须是public
Constructor的newInstance()
反射的方式,可以调用空参、带参的构造器,权限没有要求
不调用任何构适器,当前类需要实现Cloneable接口,实现clone()
从文件中、从网络中获取一个对象的二进制流

 

创建对象的步骤
判断对象释放加载、链接、初始化
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象

 

为对象分配内存
指针碰撞
如果内存是规整的,那么虚拟机将采用的是指针碰撑法( Bump The Pointer )来为对象分配内存。
意思是所有用过的内存在一边,空闲的内存在另外一边,中问放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有
compact(整理)过程的收集器时,使用指针碰撞。

 

空闲列表
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。
意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为"空闲列表(Free List ) ".

 

两种方式的决定
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

 

处理并发安全问题
CAS
采用CAS失败重试、区域加锁保证更新的原子性
TLAB
每个线程预先分配一块TLAB
一通过-XX:+/-UseTLAB参数来设定

 

初始化分配到空间
所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用

 

设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的Hashcode和对象的GC信息、锁信息等数据存储在对象的对象头中,这个过程的具体设置方式取决于JVM实现。

 

执行init方法进行初始化
在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

 

内存布局
对象头
运行时元数据:哈希值(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
类型指针:指向元数据,确定该对象所属的类型
说明:如果是数组,还要记录数组的长度

 

实例数据
它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)

1、相同宽度的字段总是被分配在一起。

2、父类中定义的变量会出现在子类之前。

3、如果CompactFields参数为true(默认true):子类的窄变量可能插入到父类变量的空隙。

 

对象填充
不是必须,也没有特别含义,仅仅起到占位符的作用。

对象访问定位
通过栈上reference访问,直接指针(Hotspot采用)

直接内存
不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义内存区域。

1、直接内存是在Java堆外的、直接向系统申请的内存区间。

2、来源于NIo,通过存在堆中的DirectByteBuffer操作Native内存

3、通常,访问直接内存的速度会优于Java堆。即读写性能高。

①因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。

②Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

1、也可能导致outofMemoryError异常

2、由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

3、缺点

①分配回收成本较高

②不受TVM内存回收管理

4、直接内存大小可以通过MaxDirectMemorysize设置

5、如果不指定,默认与堆的最大值-Xmx参数值一致

posted @ 2023-02-09 17:28  sugarstar  阅读(50)  评论(0)    收藏  举报