深入理解JVM虚拟机

深入理解JVM虚拟机JVM架构图:类装载器:沙箱安全机制:ExecutionEngine执行引擎负责解释命令,提交操作系统执行。NativeMethodStack本地方法栈:运行本地方法的栈ProgramCounterRegister即PC寄存器(程序计数器)程序计数器底层是bytecodepointer(bcp)即字节码指针PC寄存器是用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。1.它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域…

大家好,欢迎来到IT知识分享网。

文章目录

深入理解JVM虚拟机

JDK1.8新特性:

速度更快 – 红黑树
代码更少 – Lambda
强大的Stream API – Stream
便于并行 – Parallel
最大化减少空指针异常 – Optional

JVM架构图:

深入理解JVM虚拟机

类装载器知识点:

什么是类加载器
类加载器有几种
双亲委派机制
沙箱安全机制

类装载器:

深入理解JVM虚拟机
深入理解JVM虚拟机

双亲委派机制:

深入理解JVM虚拟机

双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。

双亲委派机制的优势:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

沙箱安全机制:

Java安全模型的核心就是Java沙箱(sandbox)。什么是沙箱?沙箱就是一个限制程序运行的环境。

沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。

沙箱主要限制系统资源访问,那系统资源包括什么?CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。

所有的Java程序运行都可以指定沙箱,可以定制安全策略。

Execution Engine执行引擎负责解释命令,提交操作系统执行。

深入理解JVM虚拟机

Native Method Interface本地方法接口:

一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,如C。
标识符native可以与所有其他的java标识符连用,但是abstract除外。
与Java环境外的交互
有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。
与操作系统交互
通过使用本地方法,得以用Java实现了jre的与底层系统的交互,甚至JVM的一部分就是用C写的。
Sun’Java
Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。
目前本地方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机。

Native Method Stack本地方法栈:运行本地方法的栈

Program Counter Register即PC寄存器(程序计数器)

程序计数器底层是bytecode pointer(bcp)即字节码指针

PC寄存器是用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

1.它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。

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

3.任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果实在执行native方法,则是未指定值(undefined),因为程序计数器不负责本地方法栈。

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

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

6.它是唯一一个在java虚拟机规范中没有规定任何OOM(Out Of Memery)情况的区域,而且没有垃圾回收。

Native是一个关键字,有声明,无实现,调用底层的方法。

PC寄存器面试常见问题

1.使用PC寄存器存储字节码指令地址有什么用呢?(为什么使用PC寄存器记录当前线程的执行地址呢)

(1)多线程宏观上是并行(多个事件在同一时刻同时发生)的,但实际上是并发交替执行的

(2)因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行

(3)JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令

所以,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

2.PC寄存器为什么会设定为线程私有?

(1)我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停滴做任务切换,这样必然会导致经常中断或恢复,如何保证分毫无差呢?

(2)为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

栈、堆、方法区的交互关系

深入理解JVM虚拟机

深入理解JVM虚拟机

HotSpot是使用指针的方式来访问对象。
Java堆中会存放访问类元数据的地址。
reference存储的就直接是对象的地址。

方法区理解:

方法区(Method Area) 与Java堆一样,是各个线程共享的内存区域。
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java.lang .OutofMemoryError:PermGenspace(1.8之前)或者java.lang.OutOfMemoryError: Metaspace(1.8之后),关闭JVM就会释放这个区域的内存。例如:加载大量的第三方的jar包; Tomcat 部署的工程过多(30-50个) ;大量动态的生成反射类都有可能OOM。

深入理解JVM虚拟机
幸存者0区即from区
幸存者1区即to区

Eden满了,开启GC=YGC=轻GC
Eden基本清空

s0=from
s1=to
from区和to区,它们的位置和名分不是固定的,每次GC后会交换
GC之后有交换,谁空谁是to

old养老区满了,开启Full GC = FGC
Full GC多次,发现养老区空间没办法腾出来
即发生OOM

深入理解JVM虚拟机

堆内存模型

深入理解JVM虚拟机

在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始, 使用元空间取代了永久代。本质上,方法区和永久代并不等价。仅是对hotspot而言的。
《Java 虚拟机规范》对如何实现方法区,不做统一要求。例如: BEA JRockit/ IBM J9中不存在永久代的概念。现在来看,当年使用永久代,不是好的idea。 导致Java程序更容易OOM (超过-XX : MaxPermSize上限)
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。本地内存就不容易出现OOM,但还是会。
永久代、元空间二者并不只是名字变了,内部结构也调整了。
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出O0M异常:java.lang.OutofMemoryError:PermGenspace(1.8之前)或者java.lang.OutOfMemoryError: Metaspace(1.8之后)

深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

运行时常量池vs常量池

方法区内部包含了运行时常量池。字节码文件内部包含了常量池。要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

常量池

.java经过编译后生成的.class文件,是Class文件的资源仓库,一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table) ,包括各种字面量和对类型、域和方法的符号引用。一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池(包含:数量值、字符串值、类引用、字段引用、方法引用),这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。

常量池:可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

运行时常量池

运行时常量池( Runtime Constant Pool) 是方法区的一部分。
常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期间生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。而运行时常量池期间也有可能加入新的常量(如:String.intern方法)当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

设置方法区内存大小

方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。

jdk7及以前: 通过-XX:PermSize来设置永久代初始分配空间。默认值是20.75M,-XX :MaxPe rmSi ze来设定永久代最大可分配空间。32位机器默认是64M,64位机 器模式是82M,当JVM加载的类信 息容量超过了这个值,会报异常Outo fMemoryError : PermGen space。

jdk8及以后: 元数据区大小可以使用参数-XX:MetaspaceSize和-XX :MaxMetaspaceSize指定,替代上述原有的两个参数。
默认值依赖于平台。windows下,-XX :MetaspaceSize是21M, -XX :MaxMetaspaceSize的值是-1,即没有限制。与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace
-XX:MetaspaceSize: 设置初始的元空间大小。对于64位的服务器端JVM来说, (其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一 旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。如果初始化的高水位线设置过低,上述高水位 线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX :MetaspaceSize设置为一个相对较高的值。

方法区演变细节

首先明确:只有HotSpot才有永久代。BEA JRockit、 IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一-。 Hotspot中方法区的变化如下图:

深入理解JVM虚拟机

深入理解JVM虚拟机

为什么永久代要被替换为元空间?

随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间( Metaspace )。
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
这项改动是很有必要的,原因有:
①为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生Perm区的O0M。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。默认值其实差点意思空间也不大,这个空间设置的小了,就会出现fullGC,fullGC的代价是很大的STW时间大,影响系统性能,而且可能fullGC完了后这些类可能还会被使用,那就会出现OOM导致程序终止,分配大了又觉得浪费。而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
②对永久代进行调优是很困难的。

StringTable为什么要调整?

jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在fullgc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致str ingTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

虚拟机栈也称为Java栈,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)。

Java虚拟机栈是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭)。
栈帧包括局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息。
每一个方法被调用直至执行完毕的过程,就对应这一个栈帧在虚拟机栈中从入栈到出栈的过程。

深入理解JVM虚拟机

虚拟机栈大小的调整

Java虚拟机规范允许虚拟机栈的大小固定不变或者动态扩展。

固定情况下:如果线程请求分配的栈容量超过Java虚拟机允许的最大容量,则抛出StackOverflowError异常;
可动态扩展情况下:尝试扩展的时候无法申请到足够的内存;或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,则会抛出OutOfMemoryError异常。
可以通过 java -Xss 设置 Java 线程堆栈大小,或者在idea中 help -> edit vm option中改变大小

运行时栈帧结构

每个栈帧包含5个组成部分:局部变量表、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)和一些附加信息

深入理解JVM虚拟机

JAVA的JVM的内存可分为3个区:堆(heap)、栈(stack)和方法区(method)

栈区:

1.每个线程包含一个栈区,栈中只保存方法中(不包括对象的成员变量)的基础数据类型和自定义对象的引用(不是对象),对象都存放在堆区中
2.每个栈中的数据(原始类型和对象引用)都是私有的,其他栈不能访问。
3.栈分为3个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。

堆区:

1.存储的全部是对象实例,每个对象都包含一个与之对应的class的信息(class信息存放在方法区)。
2.jvm只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身,几乎所有的对象实例和数组都在堆中分配。

方法区:

又叫静态区,跟堆一样,被所有的线程共享。它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

深入理解JVM虚拟机
深入理解JVM虚拟机

堆参数调整:

深入理解JVM虚拟机

JVM 参数选项

jvm 可配置的参数选项可以参考 Oracle 官方网站给出的相关信息:http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html
下面只列举其中的几个常用和容易掌握的配置选项:

深入理解JVM虚拟机

堆初始大小

深入理解JVM虚拟机

JVM参数设置

深入理解JVM虚拟机

打印结果

深入理解JVM虚拟机

GC是什么(分代收集算法)

次数上频繁收集Young区
次数上较少收集Old区
基本不动元空间

深入理解JVM虚拟机

深入理解JVM虚拟机

判断对象是否已死

1.引用计数法:

给对象增加一个引用计数器,每当有一个地方引用他时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不可能再次被使用的,即对象已“死”。

引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题。

2.可达性分析算法

通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为“引用链”,当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达),证明此对象是不可用的。

深入理解JVM虚拟机

对象object5,object6,object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收对象。
GC用的可达性分析算法中,哪些对象可以作为GC Roots对象

虚拟机栈中引用的对象
本地方法栈中引用的对象
方法区中静态成员或常量引用的对象

四种垃圾回收算法

1.复制算法(Copying)

jvm扫描所有对象,通过可达性分析算法标记被引用的对象,之后会申请新的内存空间,将标记的对象复制到新的内存空间里,存活的对象复制完,会清空原来的内存空间,将新的内存最为jvm的对象存储空间。这样虽然解决了内存内存碎片问题,但是如果对象很多,重新申请新的内存空间会很大,在内存不足的场景下,会对jvm运行造成很大的影响
优点:不会产生内存碎片
缺点:需要浪费额外的内存空间

深入理解JVM虚拟机
深入理解JVM虚拟机
深入理解JVM虚拟机

2.标记-清除算法(Mark-Sweep)

jvm会扫描所有的对象实例,通过可达性分析算法,将活跃对象进行标记,jvm再一次扫描所有对象,将未标记的对象进行清除,只有清除动作,不作任何的处理,这样导致的结果会存在很多的内存碎片
缺点:效率低、会造成内存碎片

深入理解JVM虚拟机

3.标记-整理算法(Mark-Compact)

标记整理实际上是在标记清除算法上的优化,执行完标记清除全过程之后,再一次对内存进行整理,将所有存活对象统一向一端移动,这样解决了内存碎片问题。
优点:不会产生内存碎片
缺点:效率低

深入理解JVM虚拟机

深入理解JVM虚拟机

4.分代收集算法

目前jvm常用回收算法就是分代回收,年轻代以复制算法为主,老年代以标记整理算法为主。原因是年轻代对象比较多,每次垃圾回收都有很多的垃圾对象回收,而且要尽可能快的减少生命周期短的对象,存活的对象较少,这时候复制算法比较适合,只要将有标记的对象复制到另一个内存区域,其余全部清除,并且复制的数量较少,效率较高;而老年代是年轻代筛选出来的对象,被标记比较高,需要删除的对象比较少,显然采用标记整理效率较高。

深入理解JVM虚拟机

GC四大算法

引用计数法
复制算法(Copying)
标记-清除算法(Mark-Sweep)
标记-整理算法(Mark-Compact)

G1垃圾收集器

G1垃圾收集器(jdk9中将G1变成默认的垃圾收集器, 整体采用标记-整理算法,局部是通过是通过复制算法,不会产生内存碎片)

G1垃圾收集器是在jdk1.7中正式使用的全新的垃圾收集器以替代CMS。
G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
第一步,开启G1垃圾收集器
第二步,设置堆的最大内存
第三步,设置最大的停顿时间
G1中提供了三种模式垃圾回收模式,Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。

原理:

G1垃圾收集器相对比其他收集器而言,最大的区别在于它取消了年轻代、老年代的物理划分,取而代之的是将堆
划分为若干个区域(Region),这些区域中包含了有逻辑上的年轻代、老年代区域。
这样做的好处就是,我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

深入理解JVM虚拟机
在G1划分的区域中,年轻代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor
空间,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。
这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。

在G1中,有一种特殊的区域,叫Humongous区域。
如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。
这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造
成负面影响。
为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型
对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

G1的Young GC工作原理
Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。
Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代
空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。
最终Eden空间的数据为空,GC停止工作,应用线程继续执行。

深入理解JVM虚拟机

Remembered Set(已记忆集合)
在GC年轻代的对象时,我们如何找到年轻代中对象的根对象呢?
根对象可能是在年轻代中,也可以在老年代中,那么老年代中的所有对象都是根么?
如果全量扫描老年代,那么这样扫描下来会耗费大量的时间。
于是,G1引进了RSet的概念。它的全称是Remembered Set,其作用是跟踪指向某个堆内的对象引用。每个Region初始化时,会初始化一个RSet,该集合用来记录并跟踪其它Region指向该Region中对象的引用,每个
Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 xx Region的 xx Card。

深入理解JVM虚拟机

Mixed GC
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即
Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region,这里需要注
意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进
行控制。也要注意的是Mixed GC 并不是 Full GC。
MixedGC什么时候触发? 由参数 -XX:InitiatingHeapOccupancyPercent=n 决定。默认:45%,该参数的意思是:
当老年代大小占整个堆大小百分比达到该阀值时触发。
它的GC步骤分2步:

1.全局并发标记(global concurrent marking)
2.拷贝存活对象(evacuation)

全局并发标记
全局并发标记,执行过程分为五个步骤:
初始标记(initial mark,STW)
标记从根节点直接可达的对象,这个阶段会执行一次年轻代GC,会产生全局停顿。
根区域扫描(root region scan)
G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。
该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回
收。
并发标记(Concurrent Marking)
G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃
圾回收中断。
重新标记(Remark,STW)
该阶段是 STW 回收,因为程序在运行,针对上一次的标记进行修正。
清除垃圾(Cleanup,STW)
清点和重置标记状态,该阶段会STW,这个阶段并不会实际上去做垃圾的收集,等待evacuation阶段来
回收。

拷贝存活对象
Evacuation阶段是全暂停的。该阶段把一部分Region里的活对象拷贝到另一部分Region中,从而实现垃圾的回收清理。

G1收集器相关参数

-XX:+UseG1GC
使用 G1 垃圾收集器
-XX:MaxGCPauseMillis
设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认值是 200 毫秒。
-XX:G1HeapRegionSize=n
设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划
分出约 2048 个区域。
默认是堆内存的1/2000。
-XX:ParallelGCThreads=n
设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多
为 8。
-XX:ConcGCThreads=n
设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。
-XX:InitiatingHeapOccupancyPercent=n
设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+PrintGCDetails -Xmx256m
#日志
[GC pause (G1 Evacuation Pause) (young), 0.0044882 secs]
[Parallel Time: 3.7 ms, GC Workers: 3]
[GC Worker Start (ms): Min: 14763.7, Avg: 14763.8, Max: 14763.8, Diff: 0.1]
#扫描根节点
[Ext Root Scanning (ms): Min: 0.2, Avg: 0.3, Max: 0.3, Diff: 0.1, Sum: 0.8]
#更新RS区域所消耗的时间
[Update RS (ms): Min: 1.8, Avg: 1.9, Max: 1.9, Diff: 0.2, Sum: 5.6]
[Processed Buffers: Min: 1, Avg: 1.7, Max: 3, Diff: 2, Sum: 5]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
#对象拷贝
[Object Copy (ms): Min: 1.1, Avg: 1.2, Max: 1.3, Diff: 0.2, Sum: 3.6]
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.2]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 3]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[GC Worker Total (ms): Min: 3.4, Avg: 3.4, Max: 3.5, Diff: 0.1, Sum: 10.3]
[GC Worker End (ms): Min: 14767.2, Avg: 14767.2, Max: 14767.3, Diff: 0.1]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.0 ms] #清空CardTable
[Other: 0.7 ms]
[Choose CSet: 0.0 ms] #选取CSet
[Ref Proc: 0.5 ms] #弱引用、软引用的处理耗时
[Ref Enq: 0.0 ms] #弱引用、软引用的入队耗时
[Redirty Cards: 0.0 ms]
[Humongous Register: 0.0 ms] #大对象区域注册耗时
[Humongous Reclaim: 0.0 ms] #大对象区域回收耗时
[Free CSet: 0.0 ms]
[Eden: 7168.0K(7168.0K)->0.0B(13.0M) Survivors: 2048.0K->2048.0K Heap:
55.5M(192.0M)->48.5M(192.0M)] #年轻代的大小统计
[Times: user=0.00 sys=0.00, real=0.00 secs]

对于G1垃圾收集器优化建议

年轻代大小
避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。
固定年轻代的大小会覆盖暂停时间目标。
暂停时间目标不要太过严苛
G1 GC 的吞吐量目标是 90% 的应用程序时间和 10%的垃圾回收时间。
评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。
固定年轻代的大小会覆盖暂停时间目标。
G1 GC 的吞吐量目标是 90% 的应用程序时间和 10%的垃圾回收时间。
评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示您愿意承受更多的垃圾回收开销,而这会直接影响到吞吐量。

JVM中4种引用和使用场景

这四种引用的级别由高到低分别为:强、软、弱、虚。
1.强引用(StrongReference)

强引用是在代码中使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

PS:强引用就是我们平时Object object = new Object()这个意思。

2.软引用(SoftReference)

如果一个对象只具有软引用,且内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

实例:实现学生信息查询操作时有两套数据操作的方案。

一、将得到的信息存放在内存中,后续查询则直接读取内存信息(优点:读取速度快;缺点:内存空间一直被占着,如果资源访问量不高,则浪费内存空间)。

二、每次查询都从数据库读取,然后填充到TO返回。(优点:内存空间将被GC回收,但不会一直被占用;缺点:在GC发生之前已有的TO依然存在,但还是执行了一次数据库查询,浪费IO);

可以通过软引用来解决。

3.弱引用(WeakReference)

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间是否足够,都会进行回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用(PhantomReference)

虚引用,顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

虚引用主要用来跟踪对象被垃圾回收器回收的活动,虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收期准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

ReferenceQueue queue = new ReferenceQueue();

PhantomReference pr = new PhantomReference(object,queue);

比较容易理解的是Java垃圾回收期会优先清理可达强度低的对象。

若一个对象的引用类型有多个,那么到底如何判断它的可达性呢?规则如下:(单弱多强)
单条引用链的可达性以最弱的一个引用类型来决定;
多条引用链的可达性以最强的一个引用类型来决定;

深入理解JVM虚拟机

我们假设图中引用①和③为强引用,⑤为软引用,⑦为弱引用,对于对象5按照这两个判断原则,路径①-⑤取最弱的引用⑤,因此该路径对对象5的引用为软引用。同样,③-⑦为弱引用。在这两条路径之间取最强的引用,于是对象5是一个软可及对象。

Java内存模型(Java Memory Model ,JMM)

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

深入理解JVM虚拟机

深入理解JVM虚拟机

JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

Java内存模型的实现

原子性

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。

有序性

在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

JVM面试题

1、内存模型以及分区,需要详细到每个区放什么。

JVM 分为堆区和栈区,还有方法区,初始化的对象放在堆里面,引用放在栈里面, class 类信息常量池(static 常量和 static 变量)等放在方法区 new:

方法区:主要是存储类信息,常量池(static 常量和 static 变量),编译后的代码(字节码)等数据堆:初始化的对象,成员变量 (那种非 static 的变量),所有的对象实例和数组都要在堆上分配栈:栈的结构是栈帧组成的,调用一个方法就压入一帧,帧上面存储局部变量表,操作数栈,方法出口等信息,局部变量表存放的是 8大基础类型加上一个应用类型,所以还是一个指向地址的指针本地方法栈:主要为 Native 方法服务程序计数器:记录当前线程执行的行号

2. 堆里面的分区:Eden,survival (from+ to),老年代,各自的特点。

堆里面分为新生代和老生代(java8 取消了永久代,采用了 Metaspace),新生代包 含 Eden+Survivor 区,survivor 区里面分为 from 和 to 区,内存回收时,如果用的是复制算法,从 from 复制到 to,当经过一次或者多次 GC 之后,存活下来的对象会被移动到老年区,当 JVM 内存不够用的时候,会触发 Full GC,清理 JVM 老年区当新生区满了之后会触发 YGC,先把存活的对象放到其中一个 Survice 区,然后进行垃圾清理。因为如果仅仅清理需要删除的对象,这样会导致内存碎片,因此一般会把 Eden 进行完全的清理,然后整理内存。那么下次 GC 的时候, 就会使用下一个 Survive,这样循环使用。如果有特别大的对象,新生代放不下,就会使用老年代的担保,直接放到老年代里面。因为 JVM 认为,一般大对象的存活时间一般比较久远。

3. GC 的两种判定方法

引用计数法:指的是如果某个地方引用了这个对象就+1,如果失效了就-1,当为 0 就会回收但是 JVM没有用这种方式,因为无法判定相互循环引用(A 引用 B,B 引用 A) 的情况。引用链法: 通过一种 GC ROOT 的对象(方法区中静态变量引用的对象等-static 变量)来判断,如果有一条链能够到达 GCROOT 就说明,不能到达 GC ROOT 就说明可以回收。

4. Minor GC 与 Full GC 分别在什么时候发生?

新生代内存不够用时候发生 MGC 也叫 YGC,JVM 内存不够的时候发生 FGC

5. 类加载的几个过程:

加载、验证、准备、解析、初始化。然后是使用和卸载了

通过全限定名来加载生成 class 对象到内存中,然后进行验证这个 class 文件,包括文件格式校验、元数据验证,字节码校验等。准备是对这个对象分配内存。解析是将符号引用转化为直接引用(指针引用),初始化就是开始执行构造器的代码

6.JVM 内存分哪几个区,每个区的作用是什么

java 虚拟机主要分为以下几个区:
方法区:

有时候也成为永久代,在该区内很少发生垃圾回收,但是并不代表不发生 GC,在这里进行的 GC 主要是对方法区里的常量池和对类型的卸载方法区主要用来存储已被虚拟机加载的类的信息、常量、静态变量和即时编译器编译后的代码等数据。该区域是被线程共享的。方法区里有一个运行时常量池,用于存放静态编译产生的字面量和符号引用。该常量池具有动态性,也就是说常量并不一定是编译时确定,运行时生成的常量也会存在这个常量池中。

虚拟机栈:

虚拟机栈也就是我们平常所称的栈内存,它为 java方法服务,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口等信息。虚拟机栈是线程私有的,它的生命周期与线程相同。局部变量表里存储的是基本数据类型、returnAddress类型(指向一条字节码指令的地址)和对象引用,这个对象引用有可能是指向对象起始地址的一个指针,也有可能是代表对象的句柄或者与对象相关联的位置。局部变量所需的内存空间在编译器间确定操作数栈的作用主要用来存储运算结果以及运算的操作数,它不同于局部变量表通过索引来访问,而是压栈和出栈的方式每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接.动态链接就是将常量池中的符号引用在运行期转化为直接引用。

本地方法栈:

本地方法栈和虚拟机栈类似,只不过本地方法栈为 Native 方法服务。

java 堆是所有线程所共享的一块内存,在虚拟机启动时创建,几乎所有的对象实例都在这里创建,因此该区域经常发生垃圾回收操作。
程序计数器
内存空间小,字节码解释器工作时通过改变这个计数值可以选取下一条需要执行的字节码,指令,分支、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内存区域是唯一一个 java 虚拟机规范没有规定任何 OOM 情况的区域。

7.如和判断一个对象是否存活?(或者 GC 对象的判定方法)

判断一个对象是否存活有两种方法:
1.引用计数法

所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收.
引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象 A 引用对象 B,对象B 又引用者对象 A,那么此时 A,B 对象的引用计数器都不为零,也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。

2.可达性算法(引用链法)

该算法的思想是:从一个被称为 GC Roots的对象开始向下搜索,如果一个对象到 GCRoots 没有任何引用链相连时,则说明此对象不可用。
在 java 中可以作为 GC Roots 的对象有以下几种:
虚拟机栈中引用的对象方法区类静态属性引用的对象方法区常量池引用的对象本地方法栈 JNI 引用的对象
虽然这些算法可以判定一个对象是否能被回收,但是当满足上述条件时,一个对象比不一定会被回收。当一个对象不可达 GC Root 时,这个对象并不会立马被回收,而是出于一个死缓的阶段,若要被真正的回收需要经历两次标记
如果对象在可达性分析中没有与 GC Root 的引用链,那么此时就会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法或者已被虚拟机调用过,那么就认为是没必要的。
如果该对象有必要执行 finalize()方法,那么这个对象将会放在一个称为 F-Queue 的对队列中,虚拟机会触发一个 Finalize()线程去执行,此线程是低优先级的,并且虚拟机不会承诺一直等待它运行完,这是因为如果 finalize()执行缓慢或者发生了死锁,那么就会造成 FQueue 队列一直等待,造成了内存回收系统的崩溃。GC 对处于 F-Queue 中的对象进行第二次被标记,这时,该对象将被移除”即将回收”集合,等待回收。

8.java 中垃圾收集的方法有哪些?

标记-清除:

这是垃圾收集算法中最基础的,根据名字就可以知道,它的思想就是标记哪些要被回收的对象,然后统一回收。这种方法很简单,但是会有两个主要问题:
1.效率不高,标记和清除的效率都很低;
2.会产生大量不连续的内存碎片,导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次 GC 动作。

复制算法:

为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清楚完第一块内存,再将第二块上的对象复制到第一块。但是这种方式,内存的代价太高,每次基本上都要浪费一般的内存。

于是将该算法进行了改进,内存区域不再是按照 1:1 去划分,而是将内存划分为8:1:1 三部分,较大那份内存交 Eden 区,其余是两块较小的内存区叫 Survior 区。

每次都会优先使用 Eden 区,若 Eden 区满,就将对象复制到第二块内存区上,然后清除 Eden 区,如果此时存活的对象太多,以至于 Survivor 不够时,会将这些对象通过分配担保机制复制到老年代中。(java 堆又分为新生代和老年代)

标记-整理:

该算法主要是为了解决标记-清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。

分代收集:

现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代和老年代。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理或者 标记-清除。

9.什么是类加载器,类加载器有哪些?

实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。

主要有一下四种类加载器:

启动类加载器(Bootstrap ClassLoader)用来加载 java 核心类库,无法被 java 程序直接引用。扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH) 来加载 Java类。一般来说,Java应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。

10. 类加载器双亲委派模型机制?

当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。

11.什么情况下会发生栈内存溢出?

1、栈是线程私有的,栈的生命周期和线程一样,每个方法在执行的时候就会创建一个栈帧,它包含局部变量表、操作数栈、动态链接、方法出口等信息,局部变量表又包括基本数据类型和对象的引用;
2、当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出StackOverFlowError异常,方法递归调用肯可能会出现该问题;
3、调整参数-xss去调整jvm栈的大小

12.怎么打破双亲委派模型?

自定义类加载器,继承ClassLoader类,重写loadClass方法和findClass方法;

13.强引用、软应用、弱引用、虚引用的区别?

强引用:

强引用是我们使用最广泛的引用,如果一个对象具有强引用,那么垃圾回收期绝对不会回收它,当内存空间不足时,垃圾回收器宁愿抛出OutOfMemoryError,也不会回收具有强引用的对象;我们可以通过显示的将强引用对象置为null,让gc认为该对象不存在引用,从而来回收它;

软引用:

软应用是用来描述一些有用但不是必须的对象,在java中用SoftReference来表示,当一个对象只有软应用时,只有当内存不足时,才会回收它;
软引用可以和引用队列联合使用,如果软引用所引用的对象被垃圾回收器所回收了,虚拟机会把这个软引用加入到与之对应的引用队列中;

弱引用:

弱引用是用来描述一些可有可无的对象,在java中用WeakReference来表示,在垃圾回收时,一旦发现一个对象只具有软引用的时候,无论当前内存空间是否充足,都会回收掉该对象;
弱引用可以和引用队列联合使用,如果弱引用所引用的对象被垃圾回收了,虚拟机会将该对象的引用加入到与之关联的引用队列中;

虚引用:

虚引用就是一种可有可无的引用,无法用来表示对象的生命周期,任何时候都可能被回收,虚引用主要使用来跟踪对象被垃圾回收的活动,虚引用和软引用与弱引用的区别在于:虚引用必须和引用队列联合使用;在进行垃圾回收的时候,如果发现一个对象只有虚引用,那么就会将这个对象的引用加入到与之关联的引用队列中,程序可以通过发现一个引用队列中是否已经加入了虚引用,来了解被引用的对象是否需要被进行垃圾回收;

总结:

JVM在一些互联网大厂是面试必问的一个技术点,所以在面试时一定要注重重点例如一些高并发高可用的技术。面试时要掌握节奏,说一些让面试官眼前一亮的技术,有些基础的东西能少说就少说,毕竟面试官面了这么多早就听够了,越是稀少的越是能激发面试官的兴趣,然后掌握在自己的节奏中。

我的学习论坛

HandsomeForum:用Java编写的学习论坛,打造我们自己的圈子!(http://huangjunjie.vip:66)
文章链接(JVM虚拟机):http://huangjunjie.vip:66/blog/read/coclkutsh49tk3vta0
文章链接(JVM面试题):http://huangjunjie.vip:66/blog/read/4naal524es8chyput4

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/14590.html

(0)

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信