启动JVM
编写一个最简单的HelloWorld类,然后运行:
public class HelloWorld {
public static void main (String[] args) throws Exception{
System.out.println("HelloWorld");
Thread.sleep(10000);
}
}
采用最原始的方式运行Java文件,打开任务管理器我们可以看到一个java.exe,是的,这个就是Java虚拟机,当10秒过后,main方法执行结束,java.exe结束,虚拟机结束,所以当我们启动一个java的main方法的时候我们就启动了Java虚拟机。
接着我们同时运行两次HelloWorld这个java类。同时就出现了两个java.exe,没错,运行了两个main方法,所以同时启动了两个Java虚拟机。
具体来说就是运行了几个main方法就启动了几个java应用,也就启动了几个虚拟机,到这里我们先认识了java虚拟机究竟是个什么玩意。
Java跨平台
当我们编写了一个HelloWorld.java文件之后,我们进行编译成class文件
接着我们只需要把class文件放到机器上就可以运行,比如window或者linux。
那么这个时候JVM就是将我们的class文件加载了之后(怎么加载?)丢到了机器识别的二进制然后进行运行。
那么在这里因为是一次编写到处运行,那么这个时候我们就会下载不同的jdk,不同的机器识别的指令是不一样的,所以我们需要安装不同的jdk,以使用我们不同的JVM去加载同一个class文件,然后丢到不同的机器上去运行。
JVM的生命周期
package com.zero;
public class jvm_01 {
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
Thread.currentThread().sleep(i*10000);
System.out.println("我是Thread线程" + i*10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
for (int i = 0; i < 100; i++) {
System.out.println("我是main方法的" + i);
}
}
}
运行该程序,然后启动任务管理器查看
当main方法for循环打印结束后,虚拟机还没有退出,而是等到Thread这个线程运行完之后才退出虚拟机,因为在虚拟机中有两种线程,分别是守护线程和非守护线程,main方法是属于非守护线程,在虚拟机中,只要有非守护线程未结束,虚拟机都不会退出,由于mian方法中启动了一个匿名线程也是一个非守护线程,所以它没有结束虚拟机也不会结束。而垃圾回收线程就是守护线程,守护线程是会自动销毁的,当虚拟机中的非守护线程全部退出之后,守护线程也就自动销毁。
非守护线程可以当做是象棋中的将军,守护线程则是象棋中将军旁边的守护者,当将军死了之后,守护者就没有存在的必要了,自然就自己销毁了。
JVM内存
在了解JVM虚拟机内存之前,我们先了解一下操作系统的内存。
首先我们了解到操作系统的内存是这样分布的,而JVM内存就是存在于我们的操作系统中的内存的一部分。
JVM内存是放在操作系统中的堆内存中,操作系统中的栈内存是由操作系统管理的,而堆内存一般是由我们去管理的,所以JVM的内存应该放在了操作系统的堆中,方便我们自己管理。
接着我们了解JVM内存结构。
我们可以发现JVM的内存结构跟操作系统中的内存结构似乎一致?原来JVM的设计模型其实就是操作系统的设计模型了,在基于操作系统的角度,JVM实际上就是一个应用,一个线程。但是基于class文件来讲,JVM就是操作系统,而JVM内存结构中的方法区,在class文件看来,就是一块“硬盘内存”。
所以概括的说,JVM内存主要分 堆(heap)、栈(stack)、方法区。
栈内存分为了虚拟机栈跟本地方法栈
堆内存分为了新生代跟老年代,新生代分为了Eden区,From Survivor区、To Survivor区。
JVM运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。图如下,如何理解划分?
简单来说就是当你喝一口水下去之后,水就分散到了各个器官比如肾、胃、肝等等 这些就好比于运行时数据区。
程序计数器:当前线程所执行的字节码的行号指示器,字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,大概就是一个计数器,里面存了下一次要执行哪一行代码。
并且由于Java虚拟机的多线程时通过线程轮流切换、分配处理器执行时间的方式来实现。那么为了线程切换后可以恢复到正确的执行位置,每条线程都必须要有一个单独的程序计数器,所以它属于一块线程私有的内存。
简单来说:有两个线程A,B同时执行这个class,那么线程A的程序计数器执行到了第9行,就会记录10,以便下一次执行,刚好线程B进来了,要执行第一行,那么线程B的计数器就是1,执行完线程B之后可能执行线程A,这个时候就知道线程A要执行到了第10行。
如果此时线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码的指令的地址。
Java虚拟机栈:虚拟机栈描述的是Java方法执行的时候的线程内存模型,每个方法在被执行的时候,Java虚拟机都会创建一个栈帧,这个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
Java虚拟机以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的一种数据结构。其存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。
- 局部变量表:一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量是以变量槽为最小单位的,一个变量槽可以存放一个32位以内的数据类型,也就是说每个槽基本应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,当然reference类型表示的是一个对象的引用地址,在这并没有很明确的指出这种引用是怎么样的数据结构。
对于64位的数据类型,比如long类型以及double类型,Java虚拟机是以高位对齐的方式为其分配两个连续的变量槽空间的。
接着我们了解Java虚拟机是如何使用局部变量表的。Java虚拟机通过索引定位的方式来使用局部变量表,索引值的范围就是从0开始一直到局部变量表最大的变量槽数量,假设有10个int类型的数值,索引就是从0到9,如果访问的是32位数据类型的变量,索引值就是N,如果访问的是64位数据类型的值,索引值会同时使用N跟N+1。并且对于两个相邻的共同存放一个64位数据类型的两个变量槽,虚拟机时不允许单独访问其中的某一个的。
-
操作数栈:一个数据运算的地方,大多数指令都在操作数栈运算,然后压栈。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入跟提取内容,也就是入栈跟出栈操作。
-
动态连接:在每个栈帧中都会包含一个该栈帧所属方法的引用,这个引用会指向运行时常量池,这个引用的目的是为了方法调用过程中的动态连接
-
方法返回地址:方法退出之后栈帧的顶端都应该是当前退出方法的上层方法,上层方法的状态会随着本次方法的返回结果而改变,而方法返回地址这块区域就是为了用来帮助栈帧去恢复上层方法的状态。简单点:A方法调B方法,B方法在A方法上面,B方法执行结束,要去找A,并告诉他结果。
-
本地方法栈
跟虚拟机栈差不多,虚拟机栈服务的是JVM执行的java方法,本地方法栈服务的对象是JVM执行的native方法。其实也是一个方法,只是这个方法可能是用c语言或者其他语言写的…….比如这种鬼东西?
方法区
一个用于存储已经被虚拟机加载的类型信息、常量、静态变量、代码缓存等数据。
heap堆
虚拟机内存中最大的一块了,也是一块所有线程共享的一块内存区域,在虚拟机启动时创建,几乎所有的对象创建都存储在堆里面了。当然Java堆就是垃圾收集器管理的内存区域。
对象的创建
我们重点研究虚拟机中堆的知识点,首先要了解java对象的创建,我们都知道Java对象创建之后会分配到我们堆内存中的Eden区域中,那么对象所需的内存大小在类加载完成后就可以确定了,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。
那么对象的对象头就包括两类信息
- 第一类是用于存储对象自身的运行时数据,比如哈希码、GC分代年龄。。。。。。等等,不说太多。这部分数据的长度在32位和64位的虚拟机中分别为32bit跟64bit。对象头
- 第二类时类型指针,即对象指向它的类型元数据的指针,干什么用的?Java虚拟机通过这个指针确定该对象是哪个类的实例。
实例数据部分存储的是对象的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的或者是子类中定义的字段。
对象的第三部分是对齐填充,并不是必然存在,也没有特别的含义,仅仅起着占位符的作用。这是因为虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,所以如果对象实例数据部分不是8的整数倍,那就第三部分来凑一下。。。
现在知道了对象的大小,就可以给对象分配内存空间了,给对象分配内存空间,其实就是在堆中划分出一块已经确定大小的内存块。
假设我们现在Eden区域有10M内存,并且在堆中的内存是绝对规整的(被使用的放在一边,已使用的放在另一边),如上图,如果现在内存中并没有对象,那么就会有一个指针指着起始位置。当给对象分配内存的时候(假设内存1M),那么指针就会移动一段与对象的大小相等距离。
这种分配方式就称为指针碰撞。
但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存是相互交错的,那就没办法用指针碰撞的方式分配内存,那么虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找出一块足够大的空间划分给此次创建的对象的大小,并且更新列表的记录,这种分配方式被称为空闲列表。而选择哪种分配方式是由堆内存是否规整决定的,而堆内存是否完整又由所采用的垃圾回收器是否带有空间压缩整理的能力决定。
当然在分配内存的时候也会出现一个问题,在并发的情况下似乎并不是安全的。什么意思呢?假设我们现在使用指针碰撞的方式分配内存,线程A创建了一个对象是1M,在指针还来不及挪动指针去分配内存的时候,线程B也创建了一个对象,原来的指针又被使用来分配内存。
至于如何解决
- 方案一:对分配内存空间的动作进行同步处理,性能较差
- 方案二:把内存分配的动作按照线程划分在不同的空间之中进行,也就是每个线程在堆中预先分配一小块内存,这种称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有当本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
分代
我们都知道在新生代实行了分代,分别是Eden区,From Survivor(S0),To Survivor(S1),并且他们所占的比例是8:1:1,那么为什么要分代呢?其本质在于对象的生命周期不一样,所以要分代。因为98%的对象在minor GC的时候会被回收掉,什么是minor GC呢?创建的对象都会分配到Eden内存,当Eden内存不够的时候,就会触发一次minor GC,这个时候就会有98%的对象被回收掉,存活的会进入S0内存或者S1内存,S0跟S1总是有一块区域是空闲的,因此新生代实际可用的内存空间为90%的新生代空间。
在经过一次Minor GC之后,使用复制算法将这些仍然存活的对象复制到另外一块Survivor中,然后清理使用的Eden区域跟Survivor,并且将对象的年龄+1,当对象的年龄达到某个值时,这些对象就会进入老年代,当然对于一些较大的对象则时直接进入老年代。
结语
小编是一枚Java Coder,业余写文章,现主营微信公众号《Java患者》,喜欢的话关注我的公众号我们一起学习Java