本文为jvm系列文章第一篇,后续会继续发布与调优实战相关的文章,以此激励和记录自己的学习历程,共勉之!!
Write Once,Run Anywhere
我们在开始入坑java这门编程语言的时候,无论是书本还是授课老师都会介绍到JAVA的一大特性:跨平台! 熟悉计算机组成原理和操作系统的同学比较容易理解,在计算机世界中,机器只识别0和1,而在这之上的汇编语言则是以符号的形式帮助开发者来替代复杂的机器码和地址,后来的C、C++、乃至于java等高级的编程语言的出现,则是以更具可读性和语言屏蔽了底层复杂的指令集,但是众所周知的是,不同的操作系统底层所识别的机器指令并不完全一致,简而言之:你在windows上开发出来的可执行文件,放到linux平台有可能是没办法执行的。那么java语言要推广,首先第一步要解决的痛点就是跨平台,这个问题如何解决?
大佬想了一个办法,既然不同的平台有各自的指令,那么在编译器和操作系统之间加一层媒介-把相同的编译后文件转而再生成当前系统支持的机器指令,就可以实现跨平台。(头发少的果然都是大佬)
于是就有了下面这样的一个图:
java大佬为不同的操作系统设计不同的java虚拟机,无论是你是在哪个平台写的代码,由java编译器编译完之后的class是一致的,然后丢给当前操作系统安装的jvm,jvm内部的字节码执行引擎和JIT等会将字节码文件再进一步转化成当前操作系统支持的机器码,这样就实现了一次编写、多处运行的目标了。(这也就解释了为什么我们初学java时安装jdk时会有不同操作系统之分的原因)。
这只是一个常识问题,但是在工作多年后有一部分人确实会忘记这俩货的区别,尽管对工作影响不大,所以有必要在这边提一下:
一言以蔽之:如果你想要开发java程序,需要安装jdk,而如果你只是希望运行编译完成的java程序,那么你就只需要jre即可。
JVM,java虚拟机,可以将其理解为它本身也是一个操作系统,既然是操作系统,就肯定有它的内存结构,它内部的内存划分和结构大概如下面这个图:(自己画的,有误区欢迎指正)
如图所示,jvm内部有类装载系统、运行时数据区、字节码执行引擎,分别介绍它们的作用:
我们常说的jvm内存,其实更多的是指运行时数据区,让我们逐一介绍它的内部组成:
GC(Garbage Collection):垃圾收集。
在程序运行期间,我们会产生很多的对象,这些对象会放在堆中,但是内存始终会有大小的瓶颈,当可用空间不足以支持我们存放对象时,jvm会触发GC。
由于FullGC需要对整个堆进行回收,通常时间都会是YoungGC的10倍,所以后续我们所有的JVM优化的宗旨都是:尽量让对象在新生代进行分配和回收,避免过多的对象进入老年代,减少FullGC的频率和次数。
要倒垃圾的前提就是要先分辨出哪一些是垃圾,JVM堆中几乎放着所有对象的实例,所以必须要判断出应该被"死亡"的对象(不会再被使用的对象)。
class Demo{
Object instance=null;
public static void main(String[] args){
Demo A=new Demo(); //A对象计数为1
Demo B=new Demo();//B对象计数为1
A.instance=B;//B对象计数为2
B.instance=A;//A对象计数为2
A=null;//A对象计数为1
B=null;//B对象计数为1
//假设此处发生GC,试想A和B对象会被回收吗?
}
}
如上面的代码所示,A和B为同类的不同对象引用,并且他们内部分别有一个指向对方实例的引用,最后A和B的引用设为null,也就是失效,如果采用引用计数法的方式,A和B对象并不会被回收,因为他们的对象引用计数不为0!但是很明显的可以看出他们的外层引用A和B都为null,内部的属性无论如何也无法再被使用,理应被当成垃圾回收掉, 这也就是引用计数法不被使用的主要原因!
可能有人会问,什么引用会被当成GC Roots,主要有以下几种:
把问题回到上一个引用计数器的代码中,GC Roots就可以是A和B这两个引用
class Demo{
Object instance=null;
public static void main(String[] args){
Demo A=new Demo(); //A为GC Root 1
Demo B=new Demo();//B为GC Root 2
A.instance=B;//从A出发,除了A自身的指向,其内部的instance属性也指向了B的对象实例
B.instance=A;//从B出发,除了B自身的指向,其内部的instance属性也指向了A的对象实例
A=null;//A不再有任何的指向
B=null;//B不再有任何的指向
//此处如果发生GC,因为以A和B为起点的GC Roots,找不到任何可以继续往下搜寻的路线,所以GC结束时,A和B new出来的两个对象就会被回收掉。
}
}
从这一点也可以看出,如果自己平时在写代码时,一些大的对象在确定不再会使用时,可以顺手给它设置为null,这样在发生GC时这些无用对象才会有机会被JVM识别并且回收掉,要记住代码的优化永远比jvm参数调优效果更快。
无论是YoungGC还是FullGC,本质上都会触发STW(Stop-The-World),也就是jvm会停止所有正在工作的业务线程,等同于时间静止,就会导致我们的业务会出现延迟、中断,具体的表现可以想象成当比如双11、大促销时,许多人在拼命下单,这时候由于对象的生成速度非常快,当频繁的发生GC或者FullGC时,客户就会觉得卡顿、页面一直转圈,这其中就有可能是因为我们的jvm正在执行垃圾回收引起的STW。
那么有没有人会问,jvm为什么在执行GC的时候需要设计出这一个STW的机制,这样不是会影响我们的业务线程工作吗?我就不STW不可以吗? --当然不可以
不妨以反证法举例,假设GC时不STW,存在以下代码:
class Test{
public static void main(String[] args){
//假设在此之前年轻代内存已满,则执行以下代码时会触发YoungGC
Object A=new Object();
//GC发现A对象满足可达性分析,标记为有效对象,线程不暂停,继续往下执行
A=null;
//此时A却已经为null,理论上应该被回收,而因为之前线程不暂停,所以此时A对象被当成了有效对象被移动到了Survivor区,这次GC并没有完全正确执行
}
}
主要分为标记和清除两个阶段,首先从GC Roots出发标记处所有有效的对象,然后清理所有没有标记的无效对象,这样做会有两个弊端:
标记-复制
同标记清除不同的是,在标记完有效对象之后,选择复制移动到另外一个区域,然后将本来的区域内存全部回收,这样的设计主要是为了避免出现内存碎片,因为对象一般是为连续空间的内存,这样复制移动和清除就保证了每次GC完成之后本来区域空间会被全部清除掉,但是代价也是显而易见的-可用内存缩小为原来的一半,浪费空间。
HotSpot 把新生代划分为一块较大的 Eden 和两块较小的 Survivor,每次分配内存只使用 Eden 和其中一块 Survivor。垃圾收集时将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 上,然后直接清理掉 Eden 和已用过的那块 Survivor。HotSpot 默认Eden 和 Survivor 的大小比例是 8:1,即每次新生代中可用空间为整个新生代的 90%。
创建对象相信很多人都很熟悉,哪里需要new哪里,但是这一个小小的指令内部的流程却并不是那么简单,接下来让我们仔细的一探究竟。
执行完类的加载后,jvm会开始在堆中(Eden)中尝试为它分配一块内存区域,对象的大小其实在加载后便可以完全确定,所以内存分配本质上就是把确定大小的内存从堆中划分出来。
这样子就会涉及一个问题:如何为对象分配内存?
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点 的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
(注:指针碰撞默认使用,但同时也取决于空间和所选择的垃圾回收器)
如图,如果java堆有内存碎片,空间利用率并不高的情况下,要分配内存就会使用空闲列表,顾名思义会维护一个列表保存所有当前可使用的内存地址,在分配内存时去取一块可用的、大小满足的内存给对象使用,然后再修改列表。
选择哪种分配方式由堆是否规整决定,堆是否规整由垃圾收集器是否有空间压缩能力决定。使用 Serial、ParNew 等收集器时,系统采用指针碰撞;使用 CMS 这种基于清除算法的垃圾收集器时,采用空间列表。
假设使用指针碰撞的方式去分配内存,那么会出现线程安全问题吗?不妨看下图:
很显然,如果多个线程同时需要创建对象,指针就成为了共享资源,在多线程环境中必然就会有多线程问题的隐患,既然线程不安全,那可以通过上锁的方式,但是使用锁又难免会影响性能,所以JVM使用CAS(Compare And Swap)和TLAB(Thread Local Allocation Buffer,TLAB)来解决.
CAS:虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。缺点:当多个线程同时分配时,CAS失败几率颇高。
TLAB:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过XX:+/ UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启XX:+UseTLAB),XX:TLABSize 指定TLAB大小,这个的设计跟ThreadLocal的以空间换时间有点类似。
在空间分配完成后,jvm需要将分配到的内存空间先初始化为零值,基本类型数据如int为0、boolean为false,引用类型为null,这样做的目的是为了对象的实例字段可以不需要赋初始值就可以提前被拿出来使用,但是不包括对象头。
初始化完零值后,jvm需要对对象本身进行一些必要的设置,比如标记当前对象属于哪个类的实例、GC的分代年龄等等,这些都被存放在Object header(对象头)上。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data) 和对齐填充(Padding)。 HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。对象头的另外一部分 是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
public class InstanceMemoryLayoutTest {
public static void main(String[] args) {
ClassLayout layout1 = ClassLayout.parseInstance(new Object());
System.out.println(layout1.toPrintable());
}
}
//结果打印
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
可能有的人会问,上面说的对象头中Mark-word占据8字节,Kclass pointer占据4字节,Object没有字段,按理来说是12字节才对,为什么结果是16? 这就涉及到一个计算机底层的概念:字节对齐。众所周知64位的cpu有64根总线,一次性可以处理8个字节,jvm设计者将对象大小始终控制为8的整倍数的好处就是:加快cpu的寻址效率,具体如下图:
在对象头中Klass Pointer中说到jvm会使用指针压缩,来压缩类源信息的对象地址,指针压缩又是什么?为什么需要开启这种机制呢? 简单的的说,指针压缩就是假设原本一个地址需要35位二进制表示,jvm会压缩成32位也就是四个字节来存储,这样可以极大的节省空间。
可能用说的比较抽象,看下图:
在这里插入图片描述
计算机世界中规定的最小内存单位为Byte(字节), 1Byte=8bit。
计算机总是以字节为单位的进行读取和存储数据,如果把内存比喻成一栋酒店,每个房间比喻成一个字节,那么如果我们需要找到某一个房间就必须要给房间定一个记号-内存地址。默认以十六进制方式表示,比如0x00000000, 那么假设目前在32位系统中,有32根总线,则我们最多可以利用这32根总线表示出2的32次方个地址=4G, 一个地址表示一个字节,所以这就是为什么32位系统最多可以使用4GB内存,当然你也可以往内存条放更大的内存,但是不好意思,超过4GB后的内存就没办法表示了,相当于是孤儿。
那么如果是在64位平台上,理论上可以表示的最大内存为2的64次方=一个灰常大的数字,目前计算机是没办法放的下这么多的内存的,那么如果一个大于4GB的内存,jvm为什么可以使用4个字节来表示呢?
前面提到java会有对象头字节按8对齐的机制,也就是说原来一个地址表示一个字节,现在表示8个字节,那么就是4GB*8=32GB, 也就是说理论上当堆内存小于32G时可以使用4字节也就是指针压缩技术来节省空间,但是当大于32GB时则还是会使用8个字节来表示和存储,这也是为什么jvm推荐内存大小不宜超过32G的原因了!
执行init方法,其实就是我们程序员所理解的:执行构造方法、为类中属性设置我们期待的值,和上面的初始化设置零值不同。
理解了GC和对象创建的一个大概的流程,我们就可以进一步的学习内存分配与回收策略。
相信很多人脑海中都会有这样一句话:new出来的对象都是放在堆上. 没错,在大多数情况下,是这样子的,但是也有一些情况会出现new出来的对象不是放在堆上,而是放在栈上!
public class EscapeTestDemo {
public static class Escape{
}
public static void main(String[] args) {
long start=System.currentTimeMillis();
for (int i = 0; i < ((1<<31)-1); i++) {
allocate();
}
long end=System.currentTimeMillis();
System.out.println(end - start);
}
public static void allocate(){
Escape escape=new Escape();
}
}
代码意图很简单,main方法执行二十多亿次循环调用allocate()方法,allocate方法只有一行,就是创建一个静态内部类的对象实例,最后输出执行的时间. 在我们的常识中,大概这二十多亿个对象(每个对象16字节),一般来说如果放在堆上那肯定很快就触发GC了,让我们运行代码观看结果(这边我打开了GC日志 -XX:+PrintGC):
可以看到,一次GC都没有触发,总执行时间4毫秒,并且新生代占用极低,老年代为0,为什么跟我们预想的情况不一样呢?
这就是著名的对象逃逸分析! 仔细看我们方法allocate,虽然它被调用了二十多亿次,但是根据我们之前所介绍的jvm栈的结构,方法压栈开辟一个栈帧,结束出栈则栈帧销毁,也就是说等于是一个栈帧重复的被创建和销毁二十多亿次,方法allocate创建的对象也没有return,或者被其他地方引用,那么jvm在默认的情况下会去分析这方法创建的对象是不是随着方法的结束而应该被销毁,就像是对象逃出了方法,本案例中的allocate方法就是典型的对象逃逸–Escape对象在方法结束时就可以被回收掉,那么为什么还要去放在堆上占用内存还要GC来回收呢?我们让对象随着方法的销毁而回收应该是最好的!比如看下面这个例子:
public User test1(){
User user=new User();
user.setId(1);
//todo 保存到数据库
return user;
}
public User test2(){
User user=new User();
user.setId(2);
//保存到数据库
}
test1方法中的user对象执行完保存动作后,就return出去了,它的作用域是不明确的,有可能外部会使用到它,所以jvm就会在堆上创建它;反之test2方法同样是创建完后执行保存动作,就随着方法结束而结束了,它的作用域是明确的,就是在test2方法中,所以这个时候jvm会把对象优先考虑放在栈中。
这里有一个参数:-XX:+DoEscapeAnalysis 对象逃逸分析参数(JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis))。
如果不相信,我们可以关闭它,然后在执行刚开始的那个例子,观察结果:
执行了5秒多,和之前的4毫秒相差数千倍!正是因为触发了很多MinorGC,导致STW带来的效率和速度的降低!
如上图,对象除开动态分析、或者栈中不足以容纳新生对象时,在一般的情况下都是优先在Eden区进行分配。当Eden区空间不足时,此时会开启一次MinorGC,使用上面提到的标记-复制算法,通过GCRoots判断出有效对象,将其复制到survivor区中的一块,然后将Eden区清空掉。
Eden:Survivor 8:1:1
默认情况,Eden和Survivor的配比是8:1:1。为什么会考虑使用这个比例呢? 因为一般情况下,触发了MinorGC后,可能会有百分之90以上的对象会被回收掉,剩余的存活对象会被挪到其中一块空的Survivor区,下一次Eden区满了又再次触发MinorGC,这个时候会把Eden区和一块使用过的Survivor区中的存活对象再次复制到另外一个空的Survivor区,因为新对象一般都是 朝生夕死.所以让Eden区足够大,Survivor够用即可,这样大多数对象都可以在Eden区被回收掉,降低GC的频率。JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变 化可以设置参数-XX:-UseAdaptiveSizePolicy!
请看以下这段代码:
public class MemoryTest {
public static void main(String[] args) {
byte[] bytes1 = new byte[35000 * 1024];//申请35m空间
byte[] bytes2 = new byte[35000 * 1024];//申请35m空间
}
}
启动指令:-Xmx200m -Xms200m -Xmn100m -XX:+PrintGCDetails
-Xms200m: 堆的初始化大小为200M
-Xmx200m:堆的最大内存为200m
-Xmn100m:年轻代空间100m
让我们启动程序,控制台输出如下:
可以看到年轻代的eden区几乎完全要被占满,整个年轻代总共89m左右,有人会问:前面设置的100m是不是没有生效? 其实这是因为jvm启动的时候内部会有一部分空间被其他的对象所占据,也就是说就算你什么事也没做,空间还是会有一些被利用的。而两个Survivor区都大概为12m,老年代为102m,都还没有被使用。
接着让我们试着再往里面分配一个大概5m左右的数组,可想而知eden区放不下,会尝试触发一次MinorGC:
public class MemoryTest {
public static void main(String[] args) {
byte[] bytes1 = new byte[35000 * 1024];//申请35m空间
byte[] bytes2 = new byte[35000 * 1024];//申请35m空间
byte[] bytes3 = new byte[5000 * 1024];//5m
}
}
可以看到发生了一次MinorGC和FullGC,最初的70m内存由于GCRoots判断引用有效,所以移动到Survivor,发现空间不足以存放,所以直接存放到了老年代,老年代空间变成70多m,Eden区直接清空,存放新进的5m数组,而后的一次FullGC一开始我也没有理解,按理来说老年代空间还有剩余不会触发FullGC,后面查询了很多资料,看到了这一篇FullGC原因,大概就是其中一句:
如果晋升到老生代的平均大小大于老生代的剩余大小,则会返回true,认为需要一次full gc
当然这边如果调整堆为300m,老年代的200m空间减去70m,也不会触发FullGC。 回到问题的关键就是–年轻代GC后的存活对象默认优先放在Surivor,放不下会晋升到老年代(按照上面这种例子,老年代会频繁触发FullGC)
先看以下代码:
public class HugeInstanceTestDemo {
public static void main(String[] args) {
final byte[] bytes = new byte[10*1024 * 1024];//10m
}
}
可以看到10m的空间放到了eden区中,在不设置任何参数的情况。
让我们修改启动参数为:-XX:PretenureSizeThreshold=10000000 -XX:+UseSerialGC
可以发现10m的对象跳过了eden区直接放到了老年代中。所以可以得出结论:在设置了大对象参数阈值和指定的垃圾回收器,超过这个阈值就会被jvm视为大对象,这时候对象就直接放到老年代,很容易理解jvm设计者为什么要这样做,因为在我们开发过程如果有一些长期存活的大对象,如果没有设置这个机制那么可能会在接下来的GC中多次的在Eden和Survivor区来回复制,这对于jvm来说无疑是徒增了压力,干脆一开始就把他们放到老年代。
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在 老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代 的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了, 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会 把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年 龄判断机制一般是在minor gc之后触发的。
年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间 如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象) 就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了 如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾, 如果回收完还是没有足够空间存放新的对象就会发生"OOM" 当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”
关于jvm内存与对象分配的机制,大概就介绍到这里,上面的内容来自我网课的学习和书籍<<深入理解jvm虚拟机>>,欢迎讨论和私信,let’s good good
study study,day day up up!