JVM内存相关
JVM内存模型
JVM运行时内存模型,主要分为线程私有和共享数据两大类;其中线程私有的包括程序计数器、虚拟线栈、本地方法区,线程共享的包括JAVA堆、方法区,其中方法区中又包含一个常量池(常量池从Java7开始移到了堆上,不在方法区了)
- 线程私有区 :
- 程序计数器 :记录正在执行的虚拟机字节码地址,生命周期与线程相同;此区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域
- 虚拟机栈 :方法执行的内存区,每个方法执行时会在虚拟机栈中创建栈桢,用于存储局部变量表、操作栈、动态链接、方法出口等信息,需要使用连续的内存空间
- 本地方法栈 :虚拟机的Native方法执行的内存区,可以与虚拟机栈合并
- 线程共享区 :
- Java堆 :JVM所管理的内存中最大的一块,是对象分配内存的区域,也是垃圾收集器管理的主要区域;所以堆中还可以细分为 :新生代和老年代,再细致一点还有Eden空间、From Survivor空间、To Survivor空间等,可以不使用连续的内存空间
- 方法区 :存放类信息、常量、静态变量、编译器编译后的代码等数据,生命周期与虚拟机相同,可以不使用连续的内存地址
- 运行时常量池 :存放编译器生成的各种字面量和符号引用
元空间、永久代、方法区有什么关系?
(Java8之前,HotSpot使用永久代实现方法区,Java8之后,HotSpot取消了永久代,改用元空间代替)
涉及到内存模型时,往往会提到永久代,那么它和方法区又是什么关系呢?《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 同时大多数用的JVM都是Sun公司的HotSpot。在HotSpot上把GC分代收集扩展至方法区,或者说使用永久代来实现方法区。因此,我们得到了结论,永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。其他的虚拟机实现并没有永久带这一说法。在1.7之前在(JDK1.2 ~ JDK6)的实现中,HotSpot 使用永久代实现方法区
对于Java8, HotSpots取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在。那么取代永久代的就是元空间。它可永久代有什么不同的?存储位置不同,永久代物理是是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。
以上摘自 :blog.csdn.net/u011635492/…
JVM内存分配与回收策略
对象的内存分配,往大方向上讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地在栈上分配)。对象主要分配在新生代的Eden区上,少数情况下也可能直接分配在老年代中。 简单来说,JVM内存分配主要采取以下几种策略 :
- 对象优先在Eden区分配 : 大多数情况下,对象在新生代Eden区中分配。当Eden区中没有足够的空间时,虚拟机将发起一次 Minor GC。
- 大对象直接进入老年代 : 所谓大对象是指,需要大量连续内存空间的Java对象;最典型的大对象就是那种很长的字符串以及数组。虚拟机提供了一个 “-XX:PretenureSizeTreshold” 参数,令大于这个设置值的对象直接在老年代分配。
- 长期存活的对象将进入老年代 : 如果对象在Eden出生并经过第一次 Minor GC 后仍存活,并且能被Survivor容纳的话,将被转移到Survivor空间中,并将对象年龄设置为1;此后,对象在Survivor区中每熬过一次 Minor GC,年龄就增加一岁;当它的年龄增加到一定程度(默认为15岁,这个阈值可以通过 “-XX:MaxTenuringThreshold” 设置),就会被晋升到老年代中。
- 动态对象年龄绑定 : 为了更好地适应不同程序的内存状况,虚拟机并不是永远的要求对象的年龄必须达到阈值才能晋升到老年代;如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象就可以直接进入老年代,而无需等到阈值中设定的年龄。
- 空间分配担保 : 在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果这个条件成立,则可以 Minor GC 可以确保是安全的;如果不成立,则判断老年代的连续空间是否大于历次晋升到老年代的对象的平均大小;如果是,则进行 Minor GC,若不是,则进行 Full GC。
内存分配过程
结合JVM内存分配策略,可以推断出大致的分配过程如下 :
1、尝试为对象在Eden区初始化一块内存区域。若Eden空间足够,内存申请结束;否则进入下一步
2、进行一次 Minor GC,释放在Eden中所有不活跃对象。若释放后Eden空间仍不足以放入新对象,则试图将部分Eden中的活跃对象放入Survivor区
3、Survivor区被用来作为Eden及Old的中间交换区域。当Old区空间足够时,Survivor区的对象将被移到Old区,否则会保留在Survivor区
4、当Old区空间不够时,JVM会在Old区进行一次 Full GC
5、若 Full GC 后,Survivor及Old区仍无法存放从Eden中复制过来的对象,则会导致JVM无法在Eden区为新对象分配内存空间,从而导致Out Of Memory
什么情况下会发生内存溢出?
- 堆溢出 :创建对象时,如果没有可以分配的堆内存,JVM就会抛出 OutOfMemoryError:java heap space 异常
- 栈溢出 :栈空间不足时,需要分以下两种情况处理 :
- 线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError
- 虚拟机在扩展栈深度时无法申请到足够的内存空间时,将抛出 OutOfMemoryError 异常
- 永久代溢出 :分为常量池溢出和方法区溢出
- 常量池溢出无法通过intern()模拟 :JDK1.7中,当常量池中没有该字符串时,intern()将不再是在常量池中创建与此String内容相同的字符串,而是改为在常量池中记录JavaHeap中首次出现的该字符串的引用,并返回该引用。也就是说,对象实际还是存储在堆上面的。所以一直用随机生成字符串并调用其intern()方法来模拟的话,最终会造成堆内存溢出。
- 直接内存溢出
Minor GC 和 Full GC
- 新生代GC(Minor GC): 指发生在新生代的垃圾收集动作,因为Java对象大多具备朝生夕死的特征,所以 Minor GC 非常频繁,一般回收速度也比较快。
- 老年代GC(Major GC/Full GC): 指发生在老年代的GC,一般来说,出现了Full GC,则至少会伴随着一次的 Minor GC(但不是绝对的,要看各个垃圾收集器各自的手机策略)。Full GC的速度一般会比 Minor GC 慢十倍以上。
垃圾收集机制
根搜索算法
根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点 GC ROOT 开始,寻找对应的引用节点;找到这个节点后,继续寻找找个节点的引用节点。当所有引用节点寻找完毕后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。
目前Java中可作为 GC ROOT 的对象有:
- 虚拟机栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象(native对象)
GC中如何判断对象是否需要被回收?
即使在可达性分析算法中不可达的对象,也并非是“非回收不可”的。要真正宣告一个对象回收,至少要经过两次标记过程 :
如果对象在进行可达性分析后发现没有与 GC ROOTs 相连接的引用链,它将会被第一次标记并进行一次筛选。筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机会将其视为“没有必要执行finalize()”,即意味着直接回收。
如果这个对象被判定有必要执行finalize()方法,则它将被放入一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。(这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或发生了死循环,将可能导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。)
finalize()方法是对象逃脱回收的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中跳出回收,只需要重新与引用链上的任何一个对象建立关联即可;否则,它就真的要被回收了。
常见的垃圾收集算法
标记-清除算法 :
标记-清除算法将垃圾回收分为两个阶段 :标记阶段和清除阶段。在标记阶段首先通根节点,标记所有从根节点出发可达的对象,未被标记的队形就是为被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。
标记-清除算法带来的一个问题是会存在大量的空间碎片,因为回收后的空间是不连续的,这用给大对象分配内存的时候可能会提前触发 Full GC。
复制算法 :
将现有的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
复制算法实现简单、运行高效,但相当于将内存缩小为了原来的一半,代价较高。
现在的商业虚拟机都采用这种收集算法回收新生代。新生代中绝大多数对象都是朝生夕死的,所以并不需要按照1:1的比例划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中的一块Survivor,当回收时,将Eden和Survivor中还存活着的对象一次性拷贝到另一个Survivor上,最后清理掉Eden和刚刚使用的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1(可以通过 “-SurvivorRattio” 来配置);当Survivor空间不够用时,需要依赖其他内存(比如说老年代)进行分配担保。
标记-整理算法 :
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的,这种情况在新生代经常发生,但在老年代,更常见的情况是大部分对象都是存活对象,这时候采用复制算法就会造成较高的成本。
标记-整理算法是一种老年代的回收算法,它在标记-清除算法的基础上做了一些优化。首先也需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端,之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的空间,因此性价比比较高。
强引用、软引用、弱引用、虚引用
- 强引用 : 指在程序代码中普遍存在的,类似 “Object obj = new Object()” 这类引用,只要强引用存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用 : 软引用用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围中并进行第二次回收。如果这次回收依然没有足够的内存,才会抛出内存溢出异常。
- 弱引用 : 弱引用也是用来描述非必需对象的,但它的强度要比软引用更弱一些。被弱引用关联的对象只能生存到下一次GC发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用 : 也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用 存在,完全不会对其生存时间构成印象,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被垃圾收集器回收时收到一个系统通知。
常用的垃圾收集器
Serial收集器 :
Serial收集器是最古老的收集器,它的缺点是当Serial收集器想进行垃圾回收时,必须暂停用户的所有进程,即stop the world。到现在为止,它依然是虚拟机运行在client模式下的默认新生代收集器。与其他收集器相比,对于限定在单个CPU的运行环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾回收,所以可以获得最高的单线程收集效率。
Serial Old收集器 :
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,采用“标记-整理”算法。
ParNew收集器 :
ParNew收集器是Serial收集器新生代的多线程实现。虽然在进行垃圾回收时依然会STW,但是相比于Serial收集器,它能够运行多条进程进行垃圾回收。
Parallel Scavenge收集器 :
Parallel是采用复制算法的多线程新生代垃圾回收器。相比于ParNew,它所关注的目标是吞吐量,即CPU用于运行用户代码的时间与CPU总消耗的时间的比值。
Parallel Old收集器 :
Parallel Old是Parallel的老年代版本,采用“标记-整理”算法。在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel+Parallel Old。
CMS收集器 :
CMS(Concurrent Mark Swep)收集器是一个非常重要的收集器,现在应用得十分广泛。它是一种以获取最短回收停顿时间为目标的收集器,从而很适合于和用户交互的业务。它是基于“标记-清除”算法实现的,收集过程分为以下四步 :
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中,初始标记和重新标记依然会造成STW,但是在耗费时间更长的并发标记和并发清除这两个阶段,都可以和用户进程同时工作。
由于CMS采用的是“标记-清除”算法,所以免会产生一些内存碎片的问题。为了解决这个问题,可以通过设置CMS参数来让CMS在进行一定次数的 Full GC 后,进行一次“标记-整理”算法,从而控制老年代的碎片数量。
G1收集器 :
G1收集器是一款面向服务端应用的垃圾收集器。与其他GC收集器相比,G1具备以下特点 :
- 并行与并发 :G1能更充分的利用CPU,使得多核环境下可通过硬件优势来缩短STW的停顿时间
- 分代收集 :和其他收集器一样,分代收集的概念在G1中依然存在,不过G1不需要其他垃圾回收器的配合即可独自管理整个GC堆
- 空间整合 :G1收集器有利于程序长时间运行,分配大对象时不会因无法得到连续的空间而提前触发GC
- 可预测的非停顿 :这是G1相当于CMS的另一大优势。虽然降低停顿时间是G1和CMS的共同关注点,但G1能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
类加载机制
类加载过程
总体上来讲,JVM类加载分为五个部分 :
加载 :
在加载阶段,会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的入口。
验证 :
这一阶段的主要目的是为了确保Class文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
准备 :
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。即在方法区中分配这些变量所使用的的内存空间。注意这里是初始值指的是,比如说声明为“public static int a = 10;”,则准备阶段过后,a的值将被设为0,而不是10;但如果声明为“public static final int a = 10;”。则会在编译阶段为a生成一个ConstantValue属性,从而在准备阶段就根据ConstantValue属性将a复制为10。
解析 :
解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。
符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标不一定加载到内存中。比如说在Java中,一个java类将会编译成一个class文件,但在编译时,java类并不知道所引用的实际地址,因此只能用符号引用来代替。各种虚拟机实现的内存布局可能有所不同,但它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用可以是指向目标的指针、相对偏移量,或一个能直接定位到目标的句柄。直接引用是与虚拟机布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,则引用的目标必定已经被加载到内存中了。
初始化 :
初始化阶段是类加载的最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义加载器以外,其他操作都由JVM主导。到了初始阶段,才开始真正执行类中定义的Java程序代码。
类加载时机
一般来说,在类的加载过程中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段则不一定 :它在某种情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。对于初始化阶段,虚拟机规范中严格规定了有且只有五种情况必须立即对类执行“初始化” :
1、遇到new、getstatic、putstatic或invokestatic这四条字节码指令时。生成这四条指令的最常见的Java代码场景是:
- 使用new关键字实例化对象的时候;
- 读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;
- 调用一个类的静态方法的时候。
2、对类进行反射调用的时候
3、初始化一个类的子类时
4、当虚拟机启动时,先初始化用户指定的主类(即包含main()方法的那个类)
5、当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。(话说回来 其实这条一直不太懂来着QAQ
哪些情况下不会执行类初始化?
1、通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
2、定义对象数组时,不会触发该类的初始化
3、常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,从而不会触发定义常量所在的类的初始化
4、通过类名获取Class对象,不会触发类的初始化
5、通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类的初始化(其实这个参数就是在告诉虚拟机,是否要对类进行初始化)
常用的类加载器
- 启动类加载器(Bootstrap ClassLoader):负责加载JAVA_HOME/lib目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可的类。它是由本地代码(指C++)实现的类加载器,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
- 扩展类加载器(Extension ClassLoader):负责加载JAVA_HOME/lib/ext目录中的,或通过java.ext.dirs系统变量指定路径中的类库;可以被开发者直接使用。
- 应用程序类加载器(Application ClassLoader):负责加载用户路径上的类库,可以被开发者直接使用;一般情况下这个就是程序中默认的类加载器。
- 用户自定义类加载器(User Custom ClassLoader):由用户根据自身需要所定制的类加载器,可以在运行期进行指定类的动态实时加载。创建用户自定义类加载器的一个重要原因就是为了能以定制的方式把类型的全限定名转换成一个Java class文件格式的字节数组。
双亲委派机制
双亲委派机制是JVM在加载类时默认采用的一种机制。简单来说就是,当某个特定的类加载器在接到加载列的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有当父加载器无法完成此加载任务时,才自己去加载。
所以,为什么要选择使用这种机制呢?
事实上,类加载器虽然只用于实现类的加载动作,但是对于任意一个类,都需要由加载它的类加载器和这个类本身共同确立其在Java虚拟机中的唯一性。通俗来说,JVM中两个类是否“相等”,首先就必须是由一个类加载器加载的,否则即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要使用的类加载器不同,那么这两个类也是不相等的。
这里的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
因此,使用双亲委派模型的好处在于,Java类随着它的类加载器一起举杯了一种带有优先级的层次关系。比如说类java.lang.Object,它存在于rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,即Object类在程序的各种类加载环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,当用户编写一个java.lang.Object的同名类并放在ClassPath中,系统中将会出现多个不同的Object类,从而造成混乱。因此,通过双亲委派机制,即使开发者编写了一个与rt.jar类库中重名的Java类,且程序可以正常编译,但也无法被加载运行。
值得注意的是,双亲委派模型是Java设计者推荐给开发者的类加载实现方式,并不是强制规定的。大多数类加载器都遵循着这个模型,但也存在着较大规模破坏双亲委托模型的情况,例如线程上下文类加载器。
其他相关
JDK命令行工具
放个链接好啦( • ̀ω•́ )✧感觉这个整理得好好 :
www.itcodemonkey.com/article/150…
Java内存结构、Java内存模型、Java对象模型
再来一个!Hollis大佬的:
www.hollischuang.com/archives/25…
剩下的看到了再补充