本站消息

站长简介


前每日优鲜python全栈开发工程师,自媒体达人,逗比程序猿,钱少话少特宅,我的公众号:想吃麻辣香锅

  java大神匠心打造,零基础java开发工程师视频教程全套,基础+进阶+项目实战,包含课件和源码

  出租广告位,需要合作请联系站长


+关注
已关注

分类  

暂无分类

标签  

暂无标签

日期归档  

2020-11(366)

2020-12(522)

2021-01(414)

2021-02(284)

2021-03(306)

图解JVM(含面试题)

发布于2021-06-08 12:55     阅读(910)     评论(0)     点赞(5)     收藏(4)


0

1

2

3

4

5

6

7

个人博客欢迎访问

总结不易,如果对你有帮助,请点赞关注支持一下
微信搜索程序dunk,关注公众号,获取以下整张完整图和思维导图

image-20210603171711303

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来

目录

文章总览

JVM基础

JVM概述

JVM:Java Virtual Machine,也就是Java虚拟机,所谓虚拟机是指:通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的计算机系统,JVM是通过软件来模拟Java字节码的指令集,是Java程序的运行环境

JVM的主要功能

  • 通过ClassLoader寻找和装载class文件
    • 解释字节码文件成为指令并执行,提供class文件的运行环境
  • 进行运行期间的内存分配和垃圾回收
  • 提供与硬件交互的平台

JVM分类

  • Sun/Oracle公司
    • 虚拟机始祖:Sun Classic/Exact VM
    • 武林盟主:HotSpot VM
    • 小家碧玉:Moblie/Embedaed VM
  • BEA公司
    • 天下第二:JRockit
    • 软硬合璧:BEA Liquid VM/Azul VM
  • IBM
    • 天下第二:J9
  • Apache/Google
    • 挑战这:Apache Harmony/Google Android Dalvik VM
  • Microsoft
    • 没有成功,但并非失败:Micosoft JVM
  • 百家争鸣…

Java内存区域与内存溢出异常

image-20210528190231019

JVM包含两个子系统和两个组件,两个子系统为Class Loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)

  • ClassLoader(类装载):根据给定的全限定名类名(如:Java.lang.Object)来装载class文件到Runtime data area中的method area
  • Execution engine(执行引擎):执行classes中的指令
  • Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口
  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存
    • Program Counter Register(程序计数器):当线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器
    • Java Virtual Machine Stacks(Java 虚拟机栈):是线程私有的,它的生命周期与线程相同,用于存储局部变量表、操作数栈、动态链接、方法出口等信息
    • Native Method Stack(本地方法栈):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的
    • Method Area(方法区):用来存储已被虚拟机加载的类信息、常量、静态变量,即时编译后的代码数据
    • Heap(堆):Java虚拟机汇中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存

本地库接口(JNI)

概述

JNI(Java Native Interface):Java本地开发接口

JNI是一个协议,这个协议用来沟通Java代码和外部的本地代码(C/C++),外部的代码也可以调用Java代码

为什么使用JNI

  • 效率上 C/C++是本地语言,比java更高效
  • 代码移植,如果之前用C语言开发过模块,可以复用已经存在的c代码
  • java反编译比C语言容易,一般加密算法都是用C语言编写,不容易被反编译

在本地方法栈中,登记了native方法,在最终执行的时候加载本地方法库中的方法通过JNI调用

运行时数据区域

PC寄存器

  • 每个线程都有一个OC寄存器,是线程私有的,用来存储指向下一条指令的地址
  • 在创建线程的时候,创建相应的Pc寄存器
  • 执行本地方法时,PC寄存器的值为undefined
  • 是一块比较小的内存空间,是唯一一个在JVM规范中没有规定OutOfMemoryError的区域

Java栈

  • 栈由一系列帧(栈帧(Frame))组成,因此Java栈也叫做帧栈,是线程私有的

  • 栈帧用来保存一个方法的局部变量、操作数栈(Java没有寄存器,所有参数传递使用操作数栈)、常量池指针、动态链接、方法返回等

  • 每一次方法调用创建一个帧,并压栈,退出方法的时候,修改栈顶指针就可以把栈帧中的内容毁掉

  • 栈的优点: 存取速度比堆块,仅次于寄存器

  • 栈的缺点:存在栈中的数据大小、生存区是在编译器决定的,缺乏灵活性

Java堆

  • 用来存放应用系统的对象和数组,所有线程共享Java堆
  • GC主要管理堆空间,对分代GC来说,堆也分代
  • 堆的优点:运行期动态分配内存大小,自动进行垃圾回收
  • 堆的缺点:效率相对较慢

方法区

  • 方法区是线程共享的,通常用来保存装载类的结构信息
  • 通常和元空间关联在一起,但具体的跟JVM实现和版本有关
  • JVM规范把方法区描述为堆的一个逻辑部分,但它有一个别名称为 Non-heap(非堆),应是为了与 Java 堆分开

静态变量、常量、类信息(构造方法、接口定义)、运行时常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关

方法区的发展

JDK8以前HotSpot设计团队选择吧收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去了专门为方法区编写内存管理代码的工作。

JDK6,常量池、静态变变量在方法区中

JDK7后,放弃弃了永久代的概念,逐步采用本地内存(Native Memory)来实现方法区的计划,把原本放在永久代的字符串常量池、静态变量等移除

到了JDK8后从,彻底废弃了永久代,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)代替,把JDK7中永久代剩余的内容(主要是类型信息)全部移到元空间

运行时常量池

  • 是Class文件中每个类或接口的常量池表,在运行期间的表示形式,通常包括:类的版本、字段、方法、接口等信息
  • 在方法区中分配
  • 通常在加载类和接口到JVM后,就创建相应的运行时常量池

本地方法栈

  • 在 JVM 中用来支持 native 方法执行的栈就是本地方法栈

完整细化JVM图

image-20210603171544953

Java程序运行机制

首先通过编译器把Java代码转化为字节码文件,类加载器(ClassLoader)再把字节码文件加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是JVM的一套指令规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution engine),将字节码翻译成底层系统指令,再交由CPU去执行,而这个过程中需要调用其他语言额本地库接口(Runtime data area)来实现整个程序的功能

image-20210528192307777

类的加载指的是将.class文件中的二进制数据读入内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构

HotSpot虚拟机对象探秘

对象的创建

对象创建的方式

Header解释
使用new关键字调用了构造函数
使用Class的newInstance方法调用了构造函数
使用Constructor类的newInstance方法调用了构造函数
使用clone方法没有调用构造函数
使用反序列化没有调用构造函数

创建对象的过程

image-20210602161915924

  • 当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是够已经被加载、解析和初始化过。如果没有那必须先执行响应的类加载过程。
  • 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从java堆中划分出来,选择那种分配方式是由Java堆的是否规整决定的
    • 指针碰撞(Bump The Pointer):假设Java堆是完全规整的,所有被使用的内存都放在一边,空闲的内存被放在另一边,中间放一个指针为分界点的指示器,所分配的方式称为”指针碰撞“。
    • 空间列表(Free List):Java堆不是规整的,已经使用的内存和空闲的内存相互交错在一起,那就没有办法简单的指针碰撞了,虚拟机需要维护一个表,记录上那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给实例对象
  • 除了如何划分空间外,还有另外一个需要考虑的问题是:对象创建中是非常频繁操作的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选的方案
    • 一种是对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
    • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行的,即每个线程预先分分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),那个线程要分配内存,就在那个线程的本地缓冲区分配,只有本地缓冲区分配用完了,分配新的缓冲区才需要同步锁定。
  • 内存分配完成之后,虚拟机必须将分配的内存空间(但不包括对象头)都初始化为零值,如果使用TLAB的话,这一项工作也可以提前值TLAB分配时顺便进行,这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值
  • 接下来Java虚拟机还要对对象进行必要的设置,例如对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希吗(实际上对象的哈希码会延后到真正调用Object::hashcode()方法时才计算)、对象的GC分代年龄等信息
  • 上面的工作完成之后,从Java虚拟机的角度来看,一个新的对象已经产生了。但从Java程序的角度看来,对象创建才刚刚开始----构造函数,即Class文件中的方法还没有执行,一般来说是否构造这个类由new指令后跟随的invokespecial指令所决定的(一般new指令后会直接执行方法,其他方式产生的类则不一定),这样一个真正可用的对象才算完全被构造出来

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

  • 对象头:包含两类信息(12B)
    • 第一类用来存储对象自身运行时数据,如哈希吗(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID、偏向时间戳等(8B),这部分的数据长度在32位和64位的虚拟机分别为32个bit个64个bit,官方称为**”Make Word“**,为了考虑到虚拟机的空间效率,”Make Word“被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间
    • 另一类是类型指针(4B),即对象执行它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
  • 实例数据:对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。(相同宽度的字段总是被放在一起存放)
  • 对齐填充:不是必然存在的,没有特别的意义,仅仅起到占位符的作用,由于HotSpot虚拟机的自动内存管理系统要求对象其实地址必须是8字节的整数倍,因此对象实例数据部分没有对齐的话,需要通过对齐填充来补全

对象的访问定位

创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象,reference类型只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问的方式由虚拟机的实现而定

  • 句柄:使用句柄的话Java堆可能会划分出来一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
image-20210601203607884
  • 直接指针:Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象的对象地址,如果只是访问对象的话,就不会多一次间接访问的开销
image-20210601203626963
  • 使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象移动(垃圾收集时移动对象是非常普遍的)时只会改变句柄中的实例数据指针,而reference不会改变
  • 使用直接指针来访问的好处是速度快,节省了一次指针定位的时间开销,由于Java对象方位在Java中非常的频繁,所以这类开销积少成多也是一项即为可观的开销,HotSpot,主要使用第二种方式进行对象的访问(有例外)

OutOfMemoryError异常

除了程序计数器以外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能

Java堆溢出

Java堆用于储存对象实例,我们只要不断的创建对象,并且保证GC Roots到对象之间有可达的路径来避免垃圾回收机制清除这些对象,那么随着对象的增加,总容量处理最大堆的容量限制后就会产生溢出异常。

解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具,对Dump出来的堆转储快照进行分析,首先要分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

内存泄漏是指被是使用的对象或者变量一直被占据在内存中,理论上来说,Java是有GC垃圾回收机制的,也就是说,不在被使用的对象,会被GC自动回收掉,自动从内存中清除

Java仍然存在内存泄漏的情况,Java导致内存泄漏的原因很明确:长生命周期的对象持有短生命周期对象的引用就可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景

虚拟机栈和本地方法栈溢出

由于 HotSpot 不区分虚拟机和本地方法栈,设置本地方法栈大小的参数没有意义,栈容量只能由 -Xss 参数来设定,存在两种异常

  • 虚拟机栈:如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够内存时,将抛出OutOfMemoryError异常

运行时常量池溢出

String的intern()方法是一个本地方法,作用是如果字符串常量池已经包含一个等于此String对象的字符串,则返回池中这个字符串的String对象的引用,否则将次String对象包含的字符串添加到常量池并返回此String对象的引用

在 JDK6 及之前常量池分配在永久代,因此可以通过 -XX:PermSize-XX:MaxPermSize 限制永久代大小,间接限制常量池。在 while 死循环中调用 intern 方法导致运行时常量池溢出。在 JDK7 后不会出现该问题,因为存放在永久代的字符串常量池已经被移至堆中

方法区溢出

方法区主要存放类型信息,如类名、访问修饰符、常量池、字段描述等,只要不断在运行时生成大量类,方法区就会溢出例如使用 JDK 反射或 CGLib 直接操作字节码在运行时生成大量的类。很多框架如 Spring、Hibernate 等对类增强时都会使用 CGLib 这类字节码技术,增强的类越多就需要越大的方法区保证动态生成的新类型可以载入内存,也就更容易导致方法区溢出

虚拟机类加载机制

概念

编写的java文件都是保存着业务逻辑代码,java编译器将.java文件编译成扩展名为.class的文件。.class文件保存着java转换后,虚拟机要执行的指令。当需要某个类的时候,java虚拟机会加载.class文件,并创建对应的class对象,将class文件加载到虚拟机内存中,这个过程被称为类的加载

加载时机

  • 隐式加载:new创建类的实例
  • 显示加载:loaderClass,forName等
  • 访问类的静态变量,或者静态变量的值
  • 调用类的静态方法
  • 调用反射方式创建某个类或者接口的Class对象
  • 初始化某个类
  • 直接使用java.exe命令来运行某个类

forName和loaderClass区别

  • Class.forName()得到的class是已经初始化完成的
  • Classloader.loaderClass()得到的是还没有连接(验证、准备、解析)的类。

加载的过程

类的生命周期

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pGuiXUh8-1622715816775)(C:/Users/dell/AppData/Roaming/Typora/typora-user-images/image-20210528203645415.png)]

  • 加载:类加载过程的一个阶段,ClassLoader通过一个类的完全限定名查找此类字节码文件,并利用字节码文件创建一个class对象
  • 验证:目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身的安全,主要包括四种验证:文件格式的验证,元数据的验证,字节码验证,符号引用验证
  • 准备:为类变量(static修饰的字段变量)分配内存并且设置该类变量的初始值,(如static int i = 5 这里只是将 i 赋值为0,在初始化的阶段再把 i 赋值为5),这里不包含final修饰的static ,因为final在编译的时候就已经分配了。这里不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象分配到Java堆中
  • 解析:这里主要的任务是把常量池中的符号引用替换成直接引用
  • 初始化:如果该类具有父类就进行对父类的初始化,执行其静态初始化器(静态代码块)和静态初始化成员变量。(前面已经对static初始化了默认值,这里需要对它进行复制,成员变量也别初始化)

类加载器的任务:根据类的全限定名来读取此类的二进制字节流到JVM中,然后转换成一个与目标类对象的相同的java.lang.Class 对象的实例,在java 虚拟机提供四种类加载器,启动类加载器、扩展加载器、系统加载器、用户自定义加载器

类加载器

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

  • 启动类加载器(Bootstrap ClassLoader):是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,或者被-Xbootclasspach参数所指定的路径中并且虚拟机识别的类库
  • 扩展类加载器(Extension ClassLoader):负责加载Java_HOME\lib\ext目录或者java\ext\dirs系统变量所指定的路径中的所有类库
  • 应用程序类加载器(Application ClassLoader)(系统类加载器):负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器

双亲委派模型

站在Java虚拟机的角度看,只存在两种不同的类加载器

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器使用C++语言实现,是虚拟机自身的一部分
  • 其他所有的类加载器:这些类都是Java语言实现,独立存在于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader

双亲委派模型

image-20210530112644317

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的福类加载器。不过这个类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父类加载器的代码,是Java设计者给开发者一种类加载器实现的最佳实践

工作过程

如果一个类加载收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有加载器请求最终都应该传送到最顶层的启动类加载器中,只要父加载器反馈给自己无法完成这个加载请求(它的搜索范围没哟找到所需的类)时,子加载器才会尝试自己去完成加载

优点

  • 一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带优先级的层次关系。例如类java.langObject,它存在放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类类加载器进行加载,因此Object类在程序的各种类加载环境中都能保证是同一个类。
  • 双亲委派机制对于保证Java程序的稳定运作很重要

双亲委派模型的实现

java.lang.ClassLoader的loadClass()方法

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        //首先检查类是否已经被加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                //如果父类加载器抛出ClassNotFoundException异常
                //说明父类加载器无法完成加载请求
                // from the non-null parent class loader
            }
            if (c == null) {
                //父类加载器无法加载时
                // If still not found, then invoke findClass in order
                //子类尝试加载当前类
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);
                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

先检查请求加载的类是否已经被加载,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父类加载器

破坏双亲委派模型

  • 双亲委派模型有一个问题:父类加载器无法向下识别子类加载器加载的资源
  • 为了解决这个问题,引入了线程上下文类加载器,可以通过Thread的setContextClassLocader()进行设置
  • 实现热部署时,比如OSGI的模块化热部署,它的类加载器就不再是严格按照双亲委派模型,很多可能就在平级的类加载器中执行了

垃圾收集器与内存分配策略

垃圾收集(Gerbage Collection,简称GC)

概述

垃圾收集需要完成的三件事情:

  • 那些内存需要回收
  • 什么时候回收
  • 如何回收

经过半个世纪的发展,今天的内存动态分配和内存回收技术已经相当成熟了,一切看起来都进入了“自动化”时代,那为什么还需要了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏的问题时,当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些‘“自动化”的技术实施必要的监控和调节

Java内存运行区域的各部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,线程灭而灭,每一个栈帧中需要分配多少内存基本上在类结构确定下来时就已知了。(即使编译器会进行一些优化)。大体上可以认为这几个区域的内存分配和回收都具备确定性,所以就不需要考虑过多的内存回收问题。

而Java堆和方法区这两个区域则有显著的不确定性,一个接口的多个实现类的内存可能会不同,一个方法所执行的不同条件分支所需要的内存可能不太一样。垃圾收集器所关注的正是这部分内存如何管理

简述Java垃圾回收机制

在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

对象已死

堆里面存放着所有的Java实例对象,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象指针还有哪些存活着,哪些已经死去了

引用计数算法

一种判断对象是否存活的算法:在对象中添加一个引用计数器,每有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1,任何时刻引用计数器值为0的对象就是不可能被使用到的对象。引用计数器只占用了一些额外的内存空间来进行计数,但他原理简单,判定效率也很高

但是Java虚拟机没有采用这种算法来管理内存,主要原因是:这个看似简单的算法有很多例外情况需要考虑,必须要配合大量的额外处理才能保证正确的工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题

可达性分析算法

Java使用可达性分析算法来判定对象是否存活,这个算法的基本思路就是通过一系列的“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索的路径称为引用链(Reference Chain),如果这个对象到GC间没有任何引用链相连,则证明这个对象不可能再被使用

image-20210602180445371

目前主流的垃圾收集器都具备了局部回收的特征,为了避免GC Roots的包含过多的对象而过度膨胀,他们在实现上做出了各种优化

回收方法区

既然回收方法区不是必须的,虽然效率低下,但是当内存不够使用的时候依然是会抛出OOM的,那么我们需要知道什么场景下需要去回收方法区。首先我们需要弄明白方法区会回收那些对象。在JDK1.7之前,常量池是在方法区中的,在此版本及以后则将其移到堆中。基于目前版本主要是1.8及以上,故我们以1.8为准。在此版本上,主要回收的是无用的类。如何判定一个类是无用的:

  • 该类的所有实例都已经被回收;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class对象没有在被任何地方被引用,无法在任何地方通过反射访问该类的方法。

因此,在大量使用反射,动态代理,CGlib等ByteCode框架,动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代也就是方法区内存不会溢出。

引用

Java中的引用类型

  • 强引用(Strongly Reference):最传统的“引用”定义,类似“Object obj = new Object()”,无论任何情况下,只要强引用还存在,垃圾收集器就不会回收掉被引用的对象
  • 软引用(Soft Reference):用来描述一些还有用,但非必须的对象。只能被软引用关联的对象,在发生内存溢出异常前,会把这些对象收进范围之中进行二次回收,SoftReference类实现软引用
  • 弱引用(Weak Reference):用来描述那些非必须的对象,在下一次GC时会被回收,WeakReference类实现弱引用
  • 虚引用(Phantom Reference):又称为幽灵引用/幻影引用,虚引用唯一的目的是为了能在这个对象被收集器回收时收到一个系统通知,PhantomReference 类实现虚引用

垃圾收集算法

从判定对象消亡的角度出发,垃圾收集算法可以分为

  • 引用计数式垃圾收集(Reference Counter GC):直接垃圾收集
  • 追踪式垃圾收集(Tracing GC):间接垃圾收集

分代收集理论

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同鉴定了多款常用的垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域去存储。显而易见,如果一个区域内的大多数对象都是朝生夕灭,难以熬过垃圾收集的过程,那么将他们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低的代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么把他们集中放在一块,虚拟机边可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用

在Java堆划分出不同区域后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而又了回收类型分类:

  • 部分收集(Partial GC):目标不是完整收集整个堆
    • 新生代收集(Minor GC):目标只是新生代垃圾收集
    • 老年代收集(Major GC):目标只是老年代垃圾收集
    • 混合收集(Mixed GC):目标是整个新生代和部分老年代
  • 整堆收集(Full GC):目标是整个Java堆方法和方法区的垃圾收集

也才能针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡

堆的分代

  • Young Gen新生代
    • Eden 伊甸园
      • From Survivor 存活区
      • To Survivor 存活区
  • Tenured Gen 老年代
  • Permanent Gen 永久代
image-20210603122723915

标记清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来

优点

实现简单,不需要对象进行移动

缺点

  • 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清楚动作,导致标记和清楚两个过程的执行效率都随对象数量增加而减少
  • 内存空间碎片话问题,标记和清除之后会产生大量不连续的内存空间,空间碎片太多可能会导致当以后程序运行过程中需要分配较大内存的对象时,而找不到空间,不得不再触发一次垃圾收集动作

标记-清除算法的执行的过程如下图所示

image-20210602210007523

标记复制算法

复制算法是将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,浪费较大

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

复制算法的执行过程如下图所示

image-20210602211936753

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性的复制到另外一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”

标记整理算法

对于老年代,回收的垃圾较少时,如果采用复制算法,则效率较低。标记整理算法的标记操作和“标记-清除”算法一致,后续操作不只是直接清理对象,而是在清理无用对象完成后让所有存活的对象都向一端移动,并更新引用其对象的指针

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。

标记-整理算法的执行过程如下图所示

image-20210602211012278

分代收集算法

当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代老年代永久代如图所示:

image-20210602233401679

垃圾回收器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用

image-20210602213150099
  • Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效
  • ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现
  • Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景
  • Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本
  • Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
  • CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间
  • G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代

CMS 垃圾回收器

CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器

CMS 使用的是标记-清除的算法实现的,所以在 GC的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低

新生代垃圾回收器和老年代垃圾回收器

  • 新生代回收器:Serial、ParNew、Parallel Scavenge
  • 老年代回收器:Serial Old、Parallel Old、CMS
  • 整堆回收器:G1

新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。

简述分代垃圾回收器是怎么工作的

分代回收器有两个分区:老年代和新生代,新生代默认的空间占比总空间的1/3,老年代默认占比2/3

新生代使用的是复制算法,新生代里面有三个分区:Eden、To Survivor、From Survivor,他们默认的占比是8:1:1,执行流程

  • 把Eden + From Survivor区存活的对象放入To Survivor区
  • 清空Eden和From Survivor分区
  • From Survivor和To Survivor分区交换,From Survivor变To Survivor,To Survivor变为From Survivor

每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代

老年代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程

image-20210602230852176

内存分配与回收策略

原则:

  1. 优先分配到Eden园:大多数情况下对象在新生代 Eden 区分配,当 Eden 没有足够空间时将发起一次 Minor GC
  2. 大对象直接分配到老年区:大对象需要连续的大量连续内存空间的对象,典型是很长的字符串或数量庞大的数组。大对象容易导致内存还有不少空间就提前触发垃圾收集已获得足够的连续的空间。 HotSpot 提供了 -XX:PretenureSizeThreshold 参数,大于该值的对象直接在老年代分配,避免在 Eden 和 Survivor间来回复制
  3. 长期存活的对象分配到老年区:虚拟机给每个对象定义了一个对象年龄技术器,存储在对象头。如果经历过第一次 Minor GC 仍然存活且能被 Survivor 容纳,该对象就会被移动到 Survivor 中并将年龄设置为 1。对象在 Survivor 中每熬过一次 Minor GC 年龄就加 1 ,当增加到一定程度(默认15)就会被晋升到老年代。对象晋升老年代的阈值可通过 -XX:MaxTenuringThreshold 设置
  4. 动态对象年龄判断:为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升老年代,如果Survivor中相同年龄所有对象的总和大于Survivor 的一半,年龄不小于该年龄的对象就可以直接进入老年代
  5. 空间分配担保:MinorGC 前虚拟机必须检查老年代最大可用连续空间是否大于新生代对象总空间,如果满足说明这次MinorGC 确定安全
    1. 如果不满足,虚拟机会查看 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果满足将冒险尝试一次 Minor GC,否则改成一次 FullGC
    2. 冒险是因为新生代使用复制算法,为了内存利用率只使用一个 Survivor,大量对象在 Minor GC 后仍然存活时,需要老年代进行分配担保,接收 Survivor 无法容纳的对象

JVM调优

JVM 调优的工具

JDK自带了很多监控工具,都位于JDK的bin目录下,其中最常用的是jconsole 和 jvisualvm 这两款视图监控工具

  • jconsole:用于对 JVM 中的内存、线程和类等进行监控;
  • jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。

设置查看堆的参数

-Xms1024m -Xmx1024m -XX:+PrintGCDetails:打印堆的信息

/**
 * @author :zsy
 * @date :Created 2021/6/1 20:47
 * @description:测试
 */
public class Test {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        long max = Runtime.getRuntime().maxMemory();
        long total = Runtime.getRuntime().totalMemory();
        System.out.println("max = " + max + "字节;" + (double)(max / 1024 / 1024) + "MB");
        System.out.println("max = " + total + "字节;" + (double)(total / 1024 / 1024) + "MB");

    }
}
输出
max = 1029177344字节;981.0MB
max = 1029177344字节;981.0MB
Heap
 PSYoungGen      total 305664K, used 20971K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
  eden space 262144K, 8% used [0x00000000eab00000,0x00000000ebf7afb8,0x00000000fab00000)
  from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
  to   space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
 ParOldGen       total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
  object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
 Metaspace       used 3277K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 358K, capacity 388K, committed 512K, reserved 1048576K

JVM调优的参数

  • -Xms2g:初始化推大小为 2g;
  • -Xmx2g:堆最大内存为 2g;
  • -XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
  • -XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
  • –XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
  • -XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
  • -XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
  • -XX:+PrintGC:开启打印 gc 信息;
  • -XX:+PrintGCDetails:打印 gc 详细信息。
  • -XX:HeapOnOutOfMemoryError:打印堆异常信息,生成*.hprof文件

使用JProfile分析工具分析OutOfMemoryError

下载Jprofile9.2版分析工具

注册码:L-Larry_Lau@163.com#5481-ucjn4a16rvd98#6038

在IDEA中进行配置

image-20210603153309281

编写测试

/**
 * @author :zsy
 * @date :Created 2021/6/3 15:12
 * @description:测试Jprofile工具
 */
public class Demo01 {
    int[] nums = new int[1 * 1024 * 1024];

    public static void main(String[] args) {
        ArrayList<Demo01> list = new ArrayList<>();
        int count = 0;
        try {
            while(true) {
                list.add(new Demo01());
                count++;
            }
        } catch (Error error) {
            error.printStackTrace();
        } finally {
            System.out.println(count);
        }

    }
}

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid15800.hprof ...
Heap dump file created [5679620 bytes in 0.030 secs]
1

根路径下查看文件,可以看到具体是什么情况导致了OutOfMemoryError异常

image-20210603153505132

JVM面试题

内存区域的划分

运行时数据区是什么?

虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干分不同的数据区,这些区域有各自的用途、创建和销毁时间

线程私有:程序计数器、Java虚拟机栈、本地方法栈

线程共享:Java堆、方法区

程序计数器是什么?

程序计数器是一块较小的内存空间,可以看做当前线程执行字节码的行号指示器,是唯一没有内存溢出的区域,字节码解释器工作时通过改变计数器的值选取下一条执行指令。分支、循环、跳转、线程恢复等功能都需要依赖计数器

如果线程正在执行java方法,计数器记录正在执行的虚拟机字节码指令地址。如果是本地方法,计数器值为Undefined

Java虚拟机栈的作用

Java虚拟机栈来描述Java方法的内存模型,每当有新的线程创建时就会分配一个栈空间,线程结束后栈空间被回收的,栈和线程拥有相同的生命周期。栈中元素用于支持虚拟机进行方法调用,每个方法在执行时都会创建一个栈帧存储局部变量表、操作栈、动态链接和方法出口等信息,每个方法从调用到执行完成,就是栈帧从入栈到出栈的过程

有两类异常:

  • 线程请求的栈深度大于虚拟机允许的深度抛出 StackOverflowError。
  • 如果 JVM 栈容量可以动态扩展,栈扩展无法申请足够内存抛出 OutOfMemoryError(HotSpot 不可动态扩展,不存在此问题)

本地方法栈的作用

本地方法栈与虚拟机栈作用相似,不同的是虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈为虚本地方法服务。调用本地方法时虚拟机栈保持不变,动态链接并直接调用指定本地方法

虚拟机规范对本地方法栈中方法的语言与数据结构无强制规定,虚拟机可自由实现,例如 HotSpot 将虚拟机栈和本地方法栈合二为一

堆的作用

堆是虚拟机所管理的内存中最大的一块,所有线程共享,在虚拟机启动时创建。堆用来存放对象实例,Java中所有的对象实例都在堆上分配内存。堆可以处于物理上不连续的内存空间,逻辑上应该连续,但对于例如数组这样的大对象,多数虚拟机实现出于简单、存储高效的考虑会要求连续的内存空间

堆既可以被实现成固定大小,也可以是可扩展的,可通过 -Xms-Xmx 设置堆的最小和最大容量,当前主流 JVM 都按照可扩展实现。如果堆没有内存完成实例分配也无法扩展,抛出 OutOfMemoryError

运行时常量池的作用

运行时常量池是方法区的一部分,Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用来存放编译器生成的各种字面量与符号,这部分内容在类加载后存放到运行时常量池,一般除了保存Class文件描述的符号引用外,还会把符号引用翻译的直接引用也存储在运行时常量池

运行时常量池相对于 Class 文件常量池的一个重要特征是动态性,Java 不要求常量只有编译期才能产生,运行期间也可以将新的常量放入池中,这种特性利用较多的是 String 的 intern 方法

直接内存是什么

直接内内存不属于运行时数据区,也不是虚拟机规范定义的内存区域,但这部分内存被频繁使用,而且可能导致内存溢出

JDK1.4 中新加入了 NIO 这种基于通道与缓冲区的 IO,它可以使用 Native 函数库直接分配堆外内存,通过一个堆里的 DirectByteBuffer 对象作为内存的引用进行操作,避免了在 Java 堆和 Native堆来回复制数据。

直接内存的分配不受 Java 堆大小的限制,但还是会受到本机总内存及处理器寻址空间限制,一般配置虚拟机参数时会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使存区域总和大于物理内存限制,导致动态扩展时出现 OOM

由直接内存导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果发现内存溢出后产生的 Dump 文件很小,而程序中又直接或间接使用了直接内存(典型的间接使用就是 NIO),那么就可以考虑检查直接内存方面的原因

深拷贝和浅拷贝

  • 浅拷贝(ShallowCopy):只是增加了一个指针指向已存在内存地址
  • 深拷贝(DeepCopy):是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存
  • 使用深拷贝的情况下,释放内存的时候不会因为出现浅拷贝时释放同一个内存的错误
  • 浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变
  • 深复制:在计算机中开辟一块新的内存地址用于存放复制的对象

堆栈的区别

区别
物理地址物理地址分配对对象是不连续的,在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)数据结构中的栈,新进后出的原则,物理地址分配是连续的
性能慢一些
内存分配时间运行期编译器
存放内容对象的实例数据和数组,因此该区更关注的是数据的存储局部变量,操作数栈,返回结果,该区更关注的是程序方法的执行
程序可见度堆对整个应用程序都是共享、可见的栈只对于线程可见,私有的
  • 静态变量存放在方法区
  • 静态对象还是存放在堆

元空间和永久代

永久代和元空间都是方法区的一种实现

永久代

永久代更规范的名字的叫做方法区,永久代是方法区的一种实现方式,方法区是java规范中定义的,只有hotspot才有永久代。之所以叫它永久代是因为垃圾回收效果很差,大部分的数据会一直存在直到程序停止运行

永久代里面一般存储类相关信息,比如类常量、字符串常量、方法代码、类的定义数据等,如果要回收永久代的空间,需要将类卸载,而类卸载的条件非常苛刻,所以空间一般回收很难。当程序中有大量动态生成类时,这些类信息都要存储到永久代,很容易造成方法区溢出

元空间

JDK6的时候HotSpot设计团队已经开始打算放弃永久代,直到JDK8,彻底的废弃了永久代的概念

元空间替代了永久代,原来存放于永久代的类信息现在放到了元空间,我们再也不会看到java.lang.OutOfMemoryError:PermGenSpace(Permanent Generationspace)的异常了,本质上来说,元空间也是方法区的一种实现

元空间是用来存放class metadata的,class metadata用于记录一个Java 类在JVM中的信息,包括但不限于JVM class file format的运行时数据:

  • Klass 结构,这个非常重要,把它理解为一个 Java 类在虚拟机内部的表示;
  • method metadata,包括方法的字节码、局部变量表、异常表、参数信息等;
  • 注解;
  • 方法计数器,记录方法被执行的次数,用来辅助 JIT 决策;
  • 其他

特点:

  • 充分利用了Java语言规范中的好处:类及相关的元数据的生命周期与类加载器的一致。
  • 每个加载器有专门的存储空间
  • 只进行线性分配
  • 不会单独回收某个类
  • 省掉了GC扫描及压缩的时间
  • 元空间里的对象的位置是固定的
  • 如果GC发现某个类加载器不再存活了,会把相关的空间整个回收掉

元空间逻辑上存在,物理上不存在

如何判断两个类是否相等?

任意一个类都必须由类加载器和这个类本身共同确立其在虚拟机中的唯一性

两个类只有由同一类加载器加载才有比较意义,否则即使两个类来源于同一个 Class 文件,被同一个 JVM 加载,只要类加载器不同,这两个类就必定不相等

参考文档

原文链接:https://blog.csdn.net/qq_45796208/article/details/117531920


站长简介:前每日优鲜python全栈开发工程师,自媒体达人,逗比程序猿,钱少话少特宅,关注我,做朋友, 我们一起谈人生和理想吧!我的公众号:想吃麻辣香锅

关注公众号回复java,免费领取 免费领取java大神匠心打造,零基础java开发工程师视频教程全套,基础+进阶+项目实战,包含课件和源码

关注公众号回复java充值+你的账号,免费为您充值1000积分

0

1

2

3

4



所属网站分类: 技术文章 > 博客

作者:小胖子爱java

链接:http://www.javaheidong.com/blog/article/219614/4d6b53696b8779ac472d/

来源:java黑洞网

任何形式的转载都请注明出处,如有侵权 一经发现 必将追究其法律责任

5 0
收藏该文
已收藏

评论内容:(最多支持255个字符)