JVM【待完善】

1.类加载

1.1 类加载过程或者说生命周期?

类从加载到虚拟机内存直到卸载出内存为止,它的整个生命周期包括:
加载-验证-准备-解析-初始化-使用-卸载,其中验证-准备-解析称为链接
图片说明
说一下类装载的执行过程?
编译的过程大致可以分为以下几个步骤:Person.java -> 词法分析器 -> tokens流 -> 语法分析器 -> 语法树/抽象语法树 -> 语义分析器-> 注解抽象语法树 -> 字节码生成器 -> Person.class文件
类装载分为以下 5 个步骤:
加载:根据查找路径找到相应的 class 文件然后导入;
验证:检查加载的 class 文件的正确性;
准备:给类中的静态变量分配内存空间;
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
初始化:对静态变量和静态代码块执行初始化工作。

描述一下JVM加载Class文件的原理机制 ?
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的加载机制。
  Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。
  类装载方式,有两种 :
隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到jvm中,
显式装载, 通过class.forName()等方法,显式加载需要的类
  Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。

1.1.1 加载阶段

在加载阶段,虚拟机需要完成下面三件事:
1)通过一个类的全限定名获取定义此类的二进制字节流
2)将这个字节流所标识的静态存储结构转化为方法区运行时数据结构
3)在内存中生成一个代表这个类的class对象,作为方法区的各种数据的访问入口

1.1.2 验证阶段

验证的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。验证阶段大致会完成下面4个阶段的检验动作:
1)文件格式验证
2)元数据验证
3)字节码验证
4)符号引用验证
字节码验证将对类的方法进行校验分析,保证被校验的方法不会做出危害虚拟机的事,一个类方法的字节码没有通过验证,肯定有问题,但通过了验证也不能说明它是安全的。

1.1.3 准备阶段?

准备阶段是正式为类变量分配内存并且设置变量的初始化值的阶段,这些变量使用的内存都将在方法区中进行分配。
(不是实例变量,且是初始值,若是public static int a=123;准备阶段后a的值是0而不是123,要初始化之后才会变成123,如果是被final修饰,public static final int a=123,在在准备阶段就是123了)

1.1.4 解析过程

解析阶段是虚拟机将常量池中的符号引用变为直接引用的过程。

1.1.5 初始化

对静态变量和静态代码块执行初始化工作。
在遇到下面情况,如果没有初始化,则需要先触发其初始化
1)使用new 关键字实例化对象
读取或者设置一个类的静态字段
调用一个类的经后台方法
2)使用java.lang.reflect包的方法对类进行反射调用,如果类没有初始化,则需要初发其初始化
3)当初始化一个类的时候,如果其父类还没初始化,必须要先初发其父类的初始化
4)虚拟机启动是,用户需要制定一个需要执行的主类(有main方法的那个类)。虚拟机会先初始化这个类。
5)当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

1.2 类加载器

实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。
JVM 类加载器作用,将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区中的运行时数据结构,在堆中生成一个代表这个类的java.lang.Class对象,作为方法区类数据的访问入口。类加载器是通过ClassLoader 及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:
图片说明
分类:
从开发人员的角度来看,类加载器分成:
1)启动类加载器,这个加载器负责把<java_home>/lib目录中或者-Xbootclasspath下的类库加载到虚拟机内存中,启动类加载器无法被java程序直接引用。
2)扩展类加载器:这个加载器负责加载<java_home>/lib/ext 下或者java.ext.dirs系统变量指定路径下所有的类库,开标这直接可以使用扩展类加载器。
3)应用类加载器:负责加载用户路径classpath上指定的类库,开发者可以直接使用这个加载器,如果程序没有自定义类加载器,一般情况使用程序默认的类加载器。
4)自定义类加载器:属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。
  加载原则:
  检查某个类是否已经加载:顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个Classloader已加载,就视为已加载此类,保证此类只所有ClassLoader加载一次。加载的顺序:加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。
当运行一个程序时,JVM启动,运行bootstrap classloader,该classloader加载核心API(此时Ext classloader和app classloader也在此时被加载),然后调用ext classloader 加载扩展API,最后app classloader加载classpath目录下定义的class,这是一个程序最基本的加载流程。
通过classloader加载类实际上就是加载的时候并不对该类进行解析,因此也不会初始化,而class类的forName方法相反,会将class进行解析和初始化。</java_home></java_home>

1.3 双亲委派

如果一个类加载器收到了类加载请求,首先它不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,每一层加载器都是如此,因此所有的加载请求最终都会传送到最顶层的启动类加载器。只有当父类加载器反馈自己无法加载时(他的搜索范围中没有找到所需要的类),子加载器才会尝试去自己加载。
好处:eg,object类。它存放在rt.jar中,无论哪个类加载器要加载这个类,最后都是委派给处在最顶端的启动类加载器完成,因此object类在程序的各种加载环境是都是同一个类。

1.4 破坏双亲委派

至于破坏双亲委派的话,总体来说有三次大规模的破坏活动:

1) 发生在双亲委派模型出现之前,即JDK1.2之前,由于双亲委派模型在JDK1.2之后才被引入,而类加载器和抽象类java.Lang.ClassLoader则在JDK1.0时代就已经存在了,面对已经存在的用户自定义类加载器的实现代码,java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.Lang.ClassLoader添加了一个新的protected方法findClass(),在此之前,用户都是去重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass().
我们之前也说了loadClass()方法的代码,双亲委派的具体逻辑就实现在这个方法之中,JDK1.2之后已不再提倡用户去覆盖loadClass方法,而是把自己的类加载逻辑 写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是复合双亲委派模型的。
2) 是由这个模型的自身的缺陷导致的,双亲委派能够很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以成为基础,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办呢?
  那jdk又是怎么做的呢?他们引入了:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.Lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围都没有设置过的话,那这个类加载器默认就是应用程序类加载器。Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等,这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由启动类加载器来加载的;SPI的实现类是由系统类加载器来加载的。启动类加载器是无法找到 SPI 的实现类的,因为依照双亲委派模型,BootstrapClassloader无法委派AppClassLoader来加载类。有了线程上下文类加载器,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上已经打破了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则。
一个典型的例子就是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,他需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI)的代码,但是启动类加载器不可能认识这些代码.

3) 是由于开发者对程序动态性的追求而导致的,这里说的“动态性”指的是当前一些非常热门的名词,代码热替换、模块热部署等,说白了就是希望应用程序能够像我们的计算机外设那样,接上鼠标、U盘不用重启机器就能使用,鼠标有问题就换个鼠标,不用停机也不用重启。对于个人计算机说来,重启一次其实没有什么,但是对于一些生产系统来说,关机重启一次可能要被列为生产事故,这种情况下热部署就有很大的吸引力。
它实现模块化热部署的关键就是它自定义的类加载器机制的实现,每一个程序模块(在OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundlle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。
我们这里简单描述下OSGi收到类加载请求的处理过程
1.将以java.*开头的类委派给父类加载器加载。
2.否则,将委派列表名单内的类委派给父类加载器加载
3.否则将Import列表中得类委派给Export这个类的Bundle的类加载器加载。
4.否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
5.否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给FragmentBundle的类加载器加载。
6.否则,查找Dynamic Import列表的Bundle,委派给对应的Bundle的类加载器加载。
7.否则,类查找失败
上面的查询顺序虽然只有开头两点复合双亲委派模型的规则,其余的类查找都是在平级的类加载器中进行的。

2. 内存模型

说一下 JVM 的主要组成部分及其作用 ?
图片说明
JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。
Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
Execution engine(执行引擎):执行classes中的指令。
Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

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

2.1 内存模型(运行时数据区)有哪些,分别是啥作用? 线程私有是哪些,线程公有是哪些,谈谈为何如此?

图片说明
包含程序计数器,java虚拟机栈,本地方法栈,堆,方法区。
线程私有:程序计数器,java虚拟机栈,本地方法栈.。
线程公有:堆,方法区。
程序计数器是当前程序执行字节码的行号指示器。
java虚拟机栈,一个个栈帧对应的是一个个被调用的方法,栈帧中包含局部变量表,操作数栈,动态链接,方法出口 returnaddress。
本地方法栈:同java虚拟机栈 不过针对的是native方法
堆:Java 虚拟机中内存最大的一块,在虚拟机启动时创建,被所有线程共享。Java对象实例以及数组都在堆上分配。
方法区:在虚拟机启动时创建。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。每个运行时常量池都是从Java虚拟机的方法区域中分配

2.1.1 程序计数器

程序计数器是一块比较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等等基础功能都需要这个计数器来完成。
由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何确定时刻,一个处理器只能执行一个线程中的指令,所以为了在线程切换过程中,确保每一个线程切换回来的时候都能回到正确的执行位置,所以每一个线程都需要独立的程序计数器,各条线程中的程序计数器互不干涉互不影响独立存储,所以程序计数器是线程私有的。
如果线程正在执行的是java方法,那么计数器指向的就是正在执行的虚拟机字节码指令的地址,如果是native方法,则值为空。此内存区域是唯一一个在java虚拟机规范中没有执行OOM情况的区域。

2.1.2 java虚拟机栈

java虚拟机栈生命周期与线程相同,它描述的是java方法执行的内存模型:
每个方法在执行过程中都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口等信息。一个方法从调用直至完成,就对应着一个栈帧从入栈到出栈的过程。
局部变量表,存储编译期可知各种基本数据类型,对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)。
long和double占两个局部变量空间,其他的数据类型占一个。
局部变量表所需要的内存空间在编译期间完成分配,进入方法时,这个方法需要帧中分配多大的局部变量空间都是完全确定的,运行期间不会改变局部变量表的大小。
对于java虚拟机规范而言,如果线程请求的栈深度大于虚拟机允许的深度则抛出stackOverflowError异常,如果虚拟机栈可以动态扩展(当前大部分java虚拟机栈可以动态扩展,同样也允许固定长度的虚拟机栈)扩展时如果无法申请到足够的内存,就会抛出OutofMemoryError。

2.1.3 本地方法栈

本地方法栈跟java虚拟机栈极其类似,不过java虚拟机栈是为java方法服务,本地方法栈为虚拟机使用到的native方法服务。
本地方法栈同样会抛出栈异常和oom异常。

2.1.4 堆

堆是java虚拟机管理内存中的最大的一块,被所有线程共享的内存区域。
虚拟机启动时创建,主要作用就是存放对象实例,几乎所有的实例对象都在这里分配内存。在java虚拟机规范中描述是所有的对象实例和数组都要在堆上分配,但是也不绝对。随着JIT编译器发展和逃逸分析技术成熟,栈上分配和标量替换优化技术导致不这么绝对。
堆也是垃圾收集器管理的主要区域,因此也被叫成GC堆。
由于现在收集器基本都采取了分代收集算法,所以java堆可以分成新生代和老年代。再细分就是eden空间,from survivor空间,to survivor空间等。
从内存分配的角度来说,堆还可能分出多个线程私有的分配缓冲区 thread local allocation buffer,存储的依然是对象实例,目标也是为了更好地回收内存,或者更快的分配内存。

2.1.5 方法区

方法区与java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等等。
对于习惯hotspot虚拟机开发的程序员来说,方法区有个别名叫永久代。
hotspot虚拟机开发团队选择吧GC分代手机扩展至方法区。这样垃圾收集器就可以像管理堆一样的来管理这部分内存,但是容易出现内存溢出的问题。
这个区域主要回收的目标是常量池的回收和对类型的卸载。

2.1.6 运行时常量池

方法区的一部分,用来存放编译期生成的各种字面量和符号引用。这部分内容在类加载后进去方法区的运行时常量池中存放。
除了保存class文件中描述的符号引用,还会把翻译出来的直接引用也丢进运行时常量池。
运行时常量池的另一个重要特征是具备动态性,没有一定要求常量在编译期产生,运行期间也可以把新的常量放入池中,这种特性用的比较多的就是string类的inern()方法。

2.1.7 直接内存

首先说明,它不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存全区域。
在JDK1.4中新增加了NIO类,引入了基于通道channel和缓冲buffer的IO方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在java堆上的DirectByteBuffer对象来作为这块内存的引用进行操作。

2.2 垃圾回收

2.2.1 判断对象是否存活方式?

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。
一般有两种方法来判断:
引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;
可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。
能作为GC Root:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等。

2.2.2 垃圾收集算法

标记清除算法
复制算法
标记整理
分代手机算法

2.2.2.1 标记-清除算法

标记-清除两个阶段,首先标记所有需要回收的对象,然后标记完毕之后统一回收。
两大问题:效率问题,标记和清除两个过程效率都不高
标记清除之后会产生大量不连续的内存碎片,空间碎片太多的话可能会导致以后程序在运行过程中需要分配较大的对象,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2.2.2.2 复制算法

将可用的内存按容量划分成大小相等的两块,每次只是用其中的一块。当这块的内存用完之后,就把还存活的对象复制到另外一块,并且清理掉这块内存。
好处在于不会出现内存碎片,但是缺点在于使用内存缩小了到原来的一半。
IBM研究表明98%的对象都是朝生夕死。所以不需要按照1:1来划分内存空间,而是按照1:1:8 来划分内存空间,采取一块eden和两块survivor区域来,每次只使用eden和其中的一块survivor。回收时把其中存活的对象复制到另外一块survivor并且清空eden和原先的survivor区域。这样每次浪费的也只有10%的区域。
如果survivor空间不够的话,需要依赖其他内存(老年代)进行分配担保。

2.2.2.3 标记整理算法

复制手机在对象存活率较高的情况下要进行比较多次的复制操作,效率会变低。如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象100%存活的极端情况。
所以针对老年代的特点提出了标记整理算法。后续动作不是对可回收对象进行清理,而是让所有存活的对象移动到一端,然后直接清理掉端边界以外的内存。

2.2.2.4 分代收集算法

当前的商业虚拟机都采取分代收集算法,根据对象存活周期不同把内存划分成几块,一般是分成新生代和老年代。新生代每次回收都有大量的对象死亡所以采取复制算法,老年代存活率高,没有额外的空间对它进行分配担保所以采取标记清除或者标记整理算法。

2.2.3 垃圾收集器

 如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,说白了就是落地咯。
串行收集器Serial:Serial、Serial Old:一个线程跑,停止,启动垃圾回收线程,回收完成,继续执行刚才暂停的线程。适用于内存比较小的嵌入式设备中。
并行收集器Parallel:Parallel Scavenge、Parallel Old,吞吐量优先,多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态,适合科学计算、后台处理等弱交互场景。
并发收集器Concurrent:CMS、G1,停顿时间优先,用户线程和垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾收集线程在执行的时候不会停顿用户程序的运行。适合于对相应时间有要求的场景,比如Web。
图片说明
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堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
  jdk11 开始引入ZGC,ZGC全称是Z Garbage Collector,是一款可伸缩(scalable)的低延迟(low latency garbage)、并发(concurrent)垃圾回收器,旨在实现以下几个目标:停顿时间不超过10ms,停顿时间不随heap大小或存活对象大小增大而增大,可以处理从几百兆到几T的内存大

2.2.3.1 CMS垃圾收集器

CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。
  CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。
  CMS(Concurrent Mark Sweep)收集器是一种以获取 最短回收停顿时间 为目标的收集器。采用的是"标记-清除算法",整个过程分为4步
初始标记 CMS initial mark ,标记GC Roots能关联到的对象 Stop The World--->速度很快。
并发标记 CMS concurrent mark,进行GC Roots跟踪(GC Roots Tracing)。
重新标记 CMS remark, 修改并发标记因用户程序变动的内容 Stop The World。
并发清除 CMS concurrent sweep。
  由于整个过程中,并发标记和并发清除,收集器线程可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。
图片说明
优点:并发收集、低停顿
缺点:产生大量空间碎片、并发阶段会降低吞吐量

2.2.4 Java内存分配与回收策率

所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面我们介绍了内存回收,这里我们再来聊聊内存分配。
  对象的内存分配通常是在 Java 堆上分配,对象主要分配在新生代的 Eden 区,如果启动了本地线程缓冲,将按照线程优先在 TLAB 上分配。少数情况下也会直接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取决于哪一种垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵循以下几种「普世」规则:
  对象优先在 Eden 区分配
  多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。
  这里我们提到 Minor GC,如果你仔细观察过 GC 日常,通常我们还能从日志中发现 Major GC/Full GC。
Minor GC 是指发生在新生代的 GC,因为 Java 对象大多都是朝生夕死,所有 Minor GC 非常频繁,一般回收速度也非常快;
Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴随至少一次 Minor GC。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。
  大对象直接进入老年代
  所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。如果大对象直接在新生代分配就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。
  长期存活对象将进入老年代
  虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在 Eden 区出生,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代。

2.2.4.1 分配担保

发生minor gc前,虚拟机会检测老年代最大可用的连续空间是否大于新生代所有对象总空间,如果大于,那么minorgc是安全的,如不成立,那么虚拟机会查看HandlePromotionFailure设置值是否允许担保试标,如果允许,那么会继续检测老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,那么尝试进行一次minor gc 尽管这次minorgc是具备风险的。如果小于或者HandlePromotionFailure 设置不允许冒险,那么会进行一次full GC

2.2.5 常见问题

2.2.5.1 JVM 运行时数据区的几种引用关系?

1)如果在栈帧中有一个变量,类型为引用类型,比如Object obj=new Object(),这时候就是典型的栈中元素指向堆中的对象。
2)方法区中会存放静态变量,常量等数据。如果是下面这种情况
private static Object obj=new Object(); 就是典型的方法区中元素指向堆中的对象。
3)堆指向方法区:方法区中会包含类的信息,堆中会有对象,那么对象是哪个类创建的,一个对象怎么知道它是由哪个类创建的,这些信息存储在Java对象的内存布局具体信息里

2.2.5.2 java对象内存布局

一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充
图片说明

2.2.5.3 JVM 内存模型 及工作原理?

一块是非堆区,JVM用永久代(PermanetGeneration)来存放方法区,(在JDK的HotSpot虚拟机中,可以认为方法区就是永久代,但是在其他类型的虚拟机中,没有永久代的概念)。一块是堆区。堆区分为两大块,一个是Old区(老年代),一个是Young区(新生代)。Young区分为两大块,一个是Survivor(S0+S1),一块是Eden区。 Eden:S0:S1=8:1:1S0和S1一样大,也可以叫From和To。图示如下:
图片说明
一般对象和数组的创建会在堆中分配内存空间,关键是堆中有这么多区域,那一个对象的创建到底在哪个区域呢?
  一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。比如有对象A,B,C等创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M,假如已经使用了100M或者达到一个设定的临界值,这时候就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect),这样的GC我们称之为Minor GC,Minor GC指得是Young区的GC。经过GC之后,有些对象就会被清理掉,有些对象可能还存活着,对于存活着的对象需要将其复制到Survivor区,然后再清空Eden区中的这些对象。
  Survivor区详解:
  由图解可以看出,Survivor区分为两块S0和S1,也可以叫做From和To。在同一个时间点上,S0和S1只能有一个区有数据,另外一个是空的。接着上面的GC来说,比如一开始只有Eden区和From中有对象,To中是空的。此时进行一次GC操作,From区中对象的年龄就会+1,我们知道Eden区中所有存活的对象会被复制到To区,From区中还能存活的对象会有两个去处。若对象年龄达到之前设置好的年龄阈值,此时对象会被移动到Old区, 如果Eden区和From区 没有达到阈值的对象会被复制到To区。 此时Eden区和From区已经被清空(被GC的对象肯定没了,没有被GC的对象都有了各自的去处)。这时候From和To交换角色,之前的From变成了To,之前的To变成了From。也就是说无论如何都要保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到To区被填满,然后会将所有对象复制到老年代中。
  Old区详解:
  从上面的分析可以看出,一般Old区都是年龄比较大的对象,或者相对超过了某个阈值的对象。在Old区也会有GC的操作,Old区的GC我们称作为Major GC 。

2.2.5.4 如何为对象分配内存

 类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:
指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。
选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

2.2.5.5 引用强度及在垃圾回收中的作用

强度依次是:强软弱虚
强引用:程序代码中普遍存在,类似于object obj=new Object()
这一类的引用,只要强引用还在,那么垃圾收集就不会收集这类引用的对象。
软引用:描述还有用但是非必须的对象。
在系统将要发生内存溢出之前,会把这些对象列为回收范围进行二次回收,如果回收完还是没有足够的内存才会抛出内存溢出异常 SoftReference

弱引用:描述非必须对象,弱引用对象只能生存到下一次垃圾收集发生之前,当垃圾收集时,无论内存是否够,都会回收弱引用。weakreference

虚引用:也被称为幽灵引用和幻影引用,对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来获取对象实例,仅仅是能在这个对象被收集器回收时收到一个系统通知。PhantomReference

2.2.5.6 finalize

对象不可达对象并非非死不可,对象真正宣告死亡,至少经历两次标记过程。如果对象经过可达性分析法发现没有与gcroots相连接的引用链,那它将会被第一次标记且进行一次筛选,筛选条件就是他是否有必要执行finalize方法,当对象没有覆盖finalize方法,或者是finalize方法已经被虚拟机调用过了,那么虚拟机认为这两种情况都属没有必要执行。
当被判断有必要执行的时候,对象会被放入F-Queue的队列,并且稍后由一个虚拟机自动建立,低优先级的线程去执行。稍后gc会对f-queue进行第二次小规模的标记,只要对象在finalize中重新跟引用链上任何一个对象建立关联即可在第二次标记中被移除F-Queue,如果还是没有建立连接则被回收。

2.2.5.7 对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄 和 直接指针 两种方式。

句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。
指针: 指向对象,代表一个对象在内存中的起始地址。如果使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

2.2.5.8 Java会存在内存泄漏吗?请简单描述

内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。
  但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。
  还有如果内存中存在大量碎片化空间,这个时候来了个大对象也有可能导致内存溢出,所有这个时候需要考虑到相应的垃圾回收算法来解决,比如标记-整理。G1 GC就是采用的这类算法。还有比如两个Survivor区,用来去碎片化。

2.2.5.9

全部评论

相关推荐

1 收藏 评论
分享
牛客网
牛客企业服务