JVM的理解:
java虚拟机是运行java字节码的虚拟机。JVM有针对不同系统的特定实现,目的是使用相同的字节码,它们都会给出相同的结果。(跨平台性)
JVM的组成
JVM是虚拟机,是一种标准规范,虚拟机有很多实现版本。如果没有特殊说明,都是针对的是 HotSpot 虚拟机。
JDK1.8之前:
JDK1.8:
上图JVM的组成(3部分):
- 执行class文件时,JVM底层首先把字节码文件通过类装载子系统加载到内存区域。
- 然后使用字节码执行引擎去执行内存里面的代码
运行时数据区域
1、虚拟机栈(线程栈)
- 一个方法对应一块栈帧内存区域,存放这个方法的局部变量。
- 存放栈帧的就是数据结构里的栈结构
(先调用的方法先分配内存,后调用的方法后分配内存,后调用的方法是先释放内存的——“先进后出”)
栈里主要存放这几类数据:局部变量表、操作数栈、动态链接、方法出口
局部变量表
放各种数据类型的变量。如果局部变量是一个对象,它的数据会存放在堆里,而局部变量表会存放这个对象指向堆的内存地址(建立了引用关系)
操作数栈
例如执行:int a = 1;在JVM编译中,先将int常量1压入操作数栈,让后再取出来 存入变量a。
操作数栈作用:操作数在程序运行中要做操作,做操作也需要有内存空间暂存一下,这块空间就是操作数栈。
动态链接
这个方法在运行过程中,它的代码在哪。根据方法入口对应的内存地址,可以找到这些代码
这个内存地址就是放在动态链接里面的
方法出口
调用方法执行完后,我要知道从哪一行代码继续执行。
调用方法的时候,就已经把当时应该返回的位置,放入了这个方法对应的栈中 方法出口 里面了
- 栈和堆的关系:因为栈会存放对象的地址,所以栈会有很多引用指针指向堆。
- 那么方法/函数如何调用?
每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
2、程序计数器
只要一个线程运行了,JVM就会在内存区域拿出一个程序计数器分配给它。
程序计数器作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
- 因为字节码文件是字节码执行引擎执行的,那么程序计数器当然也是字节码执行引擎修改的。每执行一行代码,就顺手把计数器的值修改一下。
吗
3、元空间(方法区)
方法区放的是直接内存(非运行时数据区的一部分),是线程共享的内存区域。
方法区的组成:常量 + 静态变量 + 类的信息+即时编译器编译后的代码等数据
如果静态变量是一个对象,那么方法区和堆的关系和栈堆的关系一样,是指针引用的关系
运行时常量池:
运行时常量池在 JDK6 及之前版本的 JVM 中是方法区的一部分,而在 HotSpot 虚拟机中方法区的实现是永久代。所以运行时常量池也是在永久代的。
但是JDK1.7及以后的JVM将常量池从方法区中移了出来,在堆中开辟了一块内存存放字符串常量池。
4、本地方法栈
作用和虚拟机栈发挥的作用非常相似,区别是本地方法栈是为虚拟机使用到的Native方法服务的
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
这是c语言c++有的东西,java刚出的时候很流行,为了和c的老代码进行交互,就是用的这种本地方法接口。
5、堆
JVM管理的内存中最大的一块,是所有线程共享的。此内存区域的唯一目的是存放对象实例几乎所有的对象实例以及数组都在这里分配内存。
我们new出来的对象会放在Eden
区,当放不下时,JVM会minor gc
收集垃圾。当老年代满时,会执行full gc
- Minor GC:清理年轻代空间(包括 Eden 和 Survivor 区域)
- Full GC:清理整个堆空间(包括年轻代和永久代)
Minor GC触发条件
eden区满时,触发Minor GC。即申请一个对象时,发现eden区不够用,则触发一次Minor GC。
full GC触发条件
Minor GC晋升空间大小 > 老年代连续剩余空间 ,则触发full GC。
GC的过程(运用了可达性分析算法):
- 从
GC root
出发,找引用着的对象,这一连串的对象会被标记为非垃圾对象 - 把这些非垃圾对象复制到空着的
Survivor
区里面去。 Eden
区里剩下的对象都作为垃圾对象处理掉- Eden区就空出来,新的对象就可以往里放了
Survivor区也是会放满被回收的。s0
区满了复制到s1
区;然后Eden满了会回收Eden
区和s1
区;所以一个对象在年轻代的流转是从Eden区到s0区到s1区,然后再s0和s1区之间来来回回。(每次复制到新的区,分代年龄会+1)
当对象的分代年龄加到15
后,JVM会把这种“顽固”的对象移动到老年代。
对象头:组成对象的一类数据。可以看到对象的分代年龄、锁状态等信息。
JVM调优
JDK自带一个非常好的调优工具。我们可以在cmd里输入jvisualvm
使用。这个工具一运行,就会识别计算机的所有JVM进程。我们可以看Visual GC里的参数。
JVM调优的目的:减少GC的次数,根本原因是减少STW的次数。
- JVM只要做GC,都会触发STW机制,不过
full gc
的STW时间会比较长(优先优化full gc)- STW(stop the world):当执行引擎在进行gc时,会让程序暂停。(用户会感觉卡顿了一下,如果gc次数比较多,会造成用户体验差)
- 为什么要设计STW机制?让GC进行的时候,这些对象的状态不会发生变化。
除了长期存活的对象会进入老年代,其实还有几个机制:
- 大对象直接进入老年代:放入对象大小的参数可以设置
- 对象动态年龄判断:假如当前放对象的Survivor,一部分对象的总大小大于这块Survivor内存的一半,那么大于这部分对象年龄的对象,可以直接进入老年代
- 老年代空间分配担保机制:当在新生代无法分配内存的时候,把新生代的对象转移到老生代,然后把新对象放入腾空的新生代
如果有上面的情况,直接放入老年代,那么几分钟就会被放满,就full GC了。
常用GC调优策略
策略1:尽可能将对象分配在新生代,因为full gc成本高。适当通过-Xmn
命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。
策略2:大对象如果在新生代可能会出现空间不足,导致很多小对象被分配到老年代,破坏新生代的对象结构,可能会出现频繁的GC。可以设置直接进入老年代的对象大小。-XX:PretenureSizeThreshold
策略3:合理设置进入老年代对象的年龄,减少老年代的内存占用。-XX:MaxTenuringThreshold
策略4:设置稳定的堆大小。(-Xms
初始化堆大小,-Xmx
最大堆大小)
如果满足这些指标,一般不需要进行GC优化:
- MinorGC执行时间不到50ms
- Minor执行不频繁,约10秒一次
- FullGC执行时间不到1s
- FullGC执行不频繁,不低于10分钟一次
为什么新生代内存要两个Survivor区?
- Survivor的意义就是减少被送到老年代的对象,而减少Full GC的发生。
设置两个Survivor区最大的好处就是解决了内存的碎片化。
如果只有一个Survivor区,Eden满了后触发Minor GC,Eden和Survivor都有存活对象,Eden的存活对象就会移动到Survivor区,这两部分的内存是不连续的。
内存碎片化会很影响程序的性能,堆空间被散布的对象占据不连续的内存,会导致堆中没有足够大的连续内存空间。
两个Survivor区的机制,在整个过程中,永远有一个是survivor空间是空的。但如果再细分下去,每一块的空间就会比较小,容易导致Survivor区满,所以两个区是最好的。
年轻代的特点
年轻代的特点是产生大量的死亡对象,并且要产生连续可用的内存空间,所以使用复制-清除算法和并行收集器进行垃圾回收。
老年代的特点
每次回收都只回收少量对象,一般采用标记-整理算法
另外,标记-清除算法收集垃圾的时候会产生许多内存碎片(即不连续的内存空间),此后需要为较大的对象分配内存时,若无法找到足够的连续的内存空间,就会提前触发一次GC的收集动作。
确定对象是垃圾
- 可达性分析法
根搜索算法,从 GC Root 出发,对象没有引用,就判定为无用对象
- 引用计数法
为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。
缺点:不能解决循环引用的问题。
1、标记-清除(Mark-Sweep)算法
最基础的垃圾回收算法。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。
问题:容易产生内存碎片
2、复制(Copying)算法
它将可用内存划分为相等大小的两块,每次只使用一块,当这一块的内存用完了,就将存活的对象复制到另外一块上,然后清除垃圾对象。这样就不容易出现内存碎片的问题。
问题:对内存的使用做出了高昂的代价,因为可用的内存空间减半。而且如果存活对象很多,Copying算法的效率将大大降低。
3、标记-整理(Mark-Compact)算法 (又称:压缩法)
标记阶段标记出需要回收的对象,然后将存活对象都向一端移动,然后清除端边界以外的内存。
既解决了内存碎片的问题,又充分利用了内存空间
4、分代收集(Generational Collection)算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。一般情况下将堆区划分为老年代和新生代
目前大部分垃圾收集器对于新生代采取复制算法,老年代一般使用标记-整理算法
CMS收集器(Concurrent Mark Sweep)
CMS收集器是一种以获取最短回收停顿时间(STW的时间)为目标的并发收集器。采用 标记-清除 算法实现的。
步骤:
- 初始标记(暂停所有的其他线程,标记一下GC root相连的对象,速度很快)
- 并发标记(开启GC和用户线程,用一个闭包去记录可达对象)
- 重新标记(为了修正并发标记期间因为程序的继续运行而导致标记产生变动的那一部分对象的标记记录)
- 并发清除(开启用户线程,同时GC线程对标记的区域清除)
优点:并发收集、低停顿
缺点:使用的 标记-清除 算法会导致收集结束时容易产生内存碎片
G1 收集器(Garbage-First)
步骤(类似CMS)
优点:
- 并发:利用多核处理器,缩短stw停顿时间。(甚至不用停顿)
- 分代收集:保留了分代的概念
- 空间整合:整体采用标记-整理,局部采用复制算法
- 可预测停顿:建立预测模型,预测停顿时长(是相对于CMS的大优势)
内存溢出(out of memory)
指程序在申请内存时,没有足够的内存空间供其使用。(就是内存不够用)
内存泄漏(memory leak)
指程序在申请内存后,无法释放已申请的内存空间。(堆内存对象)不再使用的对象,GC不能回收
一次的内存泄漏可以忽略,如果内存泄漏严重,会导致内存溢出。
原因:长生命周期对象 持有 短生命周期对象引用
尽管短生命周期对象已经不需要了,但是因为长生命周期对象持有它的引用而导致不能回收
例子:
- 单例模式
Instance可能早已不被使用,
但是类仍持有Instance的【引用】。
因此Intance【生命周期】和应用相同,造成内存泄漏。 - 容器
容器内的【键值对】不被使用时,
Map仍持有key对象& value对象的【引用】,
则会造成内存泄漏。
- 单例模式
引用类型(强软弱虚)
java执行GC判断对象是否存活的其中一种方式是 引用计数法引用计数:Java堆中每一个对象都有一个引用计数属性,引用每新增1次计数加1,引用每释放1次计数减1。
从jdk1.2开始,对象的引用划分了4个级别,使程序更灵活的控制对象的生命周期
- 强引用:在强引用失效前不会被GC回收
(使用最普遍的引用)
- 软引用:在内存不足的情况下,可以被GC回收
- 弱引用:只要GC线程扫描到该对象,就进行回收
- 虚引用(作用):用来跟踪GC,回收时发现这个对象有虚引用,会把虚引用加入一个引用队列。用虚引用是否存在,来判断对象是否被回收了。
HotSpot虚拟机对象探秘
Java 对象的创建过程(五步!!)
1、类加载检查
虚拟机遇到new指令时,首先会去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
2、分配内存
在类加载检查通过后,虚拟机将为新生对象分配内存,所需的内存大小在类加载完成后便可确定。
内存分配的两种方式:
取决于堆内存是否规整,内存是否规整又取决于GC收集器的算法是”标记-清除“还是”标记-整理“,复制算法内存也是规整的。
1、指针碰撞
适用场合:堆内存规整(没有内存碎片)的情况下
原理:用过的内存全部整合到一边,没有用过的内存放另一边,中间有个分界值指针,只需要向着没用过的内存方向,将该指针移动对象内存大小的位置即可
GC收集器:Serial、ParNew
2、空闲列表
适用场合:堆内存不规整的情况下
原理:虚拟机会维护一个列表,该列表会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录
GC收集器:CMS
内存分配并发问题:
在创建对象时线程安全是很重要的问题,虚拟机采用两种方式来保证线程安全。
- CAS+失败重试
- TLAB:为每一个预先在Eden区分配内存,首先在TLAB分配,当TLAB内存不够时,再采用上述的CAS进行内存分配
3、初始化零值
内存分配完成后,虚拟机要将分配到的内存空间都初始化为零值(不包括对象头)
这一步操作保证了对象的实例字段在java代码中可以不赋初始值就能直接使用,访问到这些字段数据类型所对应的零值。
4、设置对象头
初始化零值完成后,虚拟机要对对象进行必要的设置,这些信息存放在对象头中。
例如对象是哪个类的实例,对象的哈希码,GC分代年龄.........
5、执行init方法
执行 new 指令之后会接着执行
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
对象头
第一部分用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等等)
另一部分是类型指针,即对象指向它类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 示例数据
是对象真正存储的有效信息
- 对齐填充
不是必然存在的,仅仅起占位作用。Hotspit要求对象起始地址必须是 8 字节的整数倍。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种:
1、句柄:如果使用句柄的话,Java堆中会划出一块内存来作为句柄池,reference中存储的就是对象的句柄地址。
2、直接指针:如果使用直接指针访问,那么java堆对象的布局就必须考虑如何放置类型数据的相关信息,而reference中存储的直接就是对象的地址
两种对象访问的方式各有优势。
- 使用句柄的好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不用修改
- 使用直接指针的好处是速度快,节省了一次指针定位的时间开销
类加载过程
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析
加载
- 通过全类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构 转换为 方法区的运行时数据结构
- 在内存中生成一个代表该类的Class对象,作为方法区数据的访问入口
加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。其中,类加载器、双亲委派模型也是非常重要的知识点。
验证
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
- 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
- 初始值"通常情况"下是数据类型默认的零值(如0、0L、null、false等)
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类、字段、方法在内存中的指针或者偏移量。
符号引用:一组符号来描述目标,可以是任何字面量。直接引用:直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
初始化是类加载的最后一步,也是真正执行类中定义的java程序代码(字节码),初始化阶段是执行类加载器clinit()
方法的过程。
- 对于初始化阶段,虚拟机严格规范了5种情况,必须对类进行初始化
- 当遇到 new 、 getstatic、putstatic或invokestatic 这4条直接码指令时
- 使用
java.lang.reflect
包的方法对类进行反射调用时 ,如果类没初始化,需要触发其初始化- 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
- 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
- 当使用 JDK1.7 的动态动态语言时,如果一个 MethodHandle 实例的最后解析结构为 REF_getStatic、REF_putStatic、REF_invokeStatic、的方法句柄,并且这个句柄没有初始化,则需要先触发器初始化。