JVM
虚拟机
虚拟机的职责
-
加载指定的字节码文件
-
将字节码文件加载到内存中去
-
虚拟机的优化
-
类的生命周期(卖假烟准结仇,是邪恶的)
-
加载:1.通过全限定名将对应的类(字节码文件)转换成二进制流;2.将这个字节流的静态结构(比如一些构造方法)存入方法区;3.在内存中生成一个该类对应的大Class对象。虚拟机规定需要将字节码变成二进制流,但是没有规定二进制流从哪里来,所以可以是解压zip包,从网路中获取,动态代理生成,由其他文件生成(比如jsp应用)
-
验证:连接包括验证,准备,解析三部分,加载和连接是交叉进行的,在加载的同时可能部分字节码文件就在进行连接中的验证,验证是连接的第一步,目的就是保证加载的字节码文件是合法的,如果不合法也让进的话,可能导致虚拟机崩溃。验证分为四步:1.文件格式验证(必须是字节码文件);2.元数据验证(比如有没有父类,父类能不能被继承);3.字节码验证,确保字节码不会危害虚拟机安全;4.符号验证(校验符号中引用的权限定名是否能找到对应的类,是否有对应的权限),目的是确保解析能正常进行
-
准备:为类变量(static修饰的变量)分配内存空间并设置初始值,这些变量都是放在方法区的,而实例对象的变量都是随实例对象创建而存在于堆中的,类变量设置初始值是初始化零值而不是赋值,比如public static int value = 123,此时value设置为0而不是123,给value赋值的指令是在初始化的时候执行的,如果是被final修饰的类常量,那么就会在该准备阶段直接赋值,比如public static final int value = 123,那么此时就是直接设置value为123
-
解析:将虚拟机常量池中的符号引用替换为直接引用(java类编译成字节码文件时,在编译时是不知道所引用类的真实地址的,只能通过符号来代替,直接引用是和虚拟机布局相关的,如果有了直接引用,那么引用的目标必定已经被加载进内存中了)
-
初始化:初始化阶段才真正开始执行类中定义的java代码,初始化阶段是执行类构造器<clinit>()方法的过程,clinit是用来执行静态代码块和静态变量的,如果没有静态代码块和类变量,就不会生成类构造器(clinit),静态代码块只能访问定义在静态代码块以前的变量,定义之后的只能赋值不能访问(因为编译器收集的类变量和static语句块是按源文件中出现的顺序决定的)
-
使用
-
卸载
-
-
初始化:有且只有5中情况会立即对类进行初始化(加载,验证,准备,解析必须在初始化之前执行)
-
遇到new,getstatic,putstatic(读取或者设置一个静态变量时,除了final修饰的,在编译时期就已经把结果放在常量区的静态字段外)和invokestatic(调用类的静态方法)这4条指令时,如果类没有进行初始化,那么要先触发其初始化
-
使用反射的时候
-
当初始化一个类的时候,如果发现其父类还没初始化,那么要先初始化其父类,但是接口除外,接口不要求所有的父接口初始化,只有真正调用到父接口的时候才去初始化
-
当启动虚拟机的时候,用户需要指定一个执行的主类,虚拟机会先初始化这个指定的类
-
使用java7的新特新动态语言支持,如果一个Methodhandle实例在解析时,该方法对应的类还没有初始化,那么要先触发其初始化
-
-
不会初始化的情况
-
通过子类引用父类静态字段,不会初始化子类(因为静态字段是父类的,所以通过父类直接访问)
- 通过数组定义来引用类,不会触发此类的初始化(有加载,但是没有初始化) 数组引用类.png
-
- 常量在编译的阶段会存入调用类的常量池中,本质上是没有用到定义常量的类,所以不会初始化定义的类
-
类加载器分类:
-
启动类加载器(Bootstrap Classloader),是虚拟机自带的:负责加载<JAVA_HOME>\lib中的类库(当然这些类库必须是合法的,否则也不行)
-
java语言编写独立于虚拟机外部并且都继承于java.lang.ClassLoader的类加载器
-
扩展类加载器Extension ClassLoader:加载<JAVA_HOME>\lib\ext中的类库
- 应用程序加载器Applicatioon ClassLoader:加载用户路径(classpath)上所指定的类库 类加载器.png
-
-
加载过程:
-
一个类加载时会先给到它的父类加载器(不是真正的父类,只是一种组合关系),父类加载器会继续往上给到自己的父类加载器,直到启动类加载器为止,然后启动类加载器从jdk中开始找,看看能不能被加载,如果不行就往回走,交给扩展类加载器处理,扩展类加载器如果也处理不了就给到应用程序加载器,一次类推,直到能加载为止,并且只加载一次
-
双亲委派的目的是避免重复,当父加载器已经加载后,子加载器就没有必有再加载,双亲委派模型要求除了启动类加载器外,每个类都要有父加载器(没有继承关系,而是使用组合关系来组织层级)
-
双亲委派模式可以被打破(前提是要有足够的意义和理由),如何打破呢:1.自定义加载器,复写loadClass方法;2.启用线程的上下文类加载器对象
-
-
运行时数据区:
-
jvm管理当前应用的内存(内存分配和内存销毁)
-
jvm是模拟的计算机环境,字节码文件是运行在jvm中的,jvm会管理针对这个应用程序的内存
-
java内存模型分为:方法区(常量池就在方法区),堆区,这两个是被所有的线程共享的,线程私有的是:本地方法栈,虚拟机栈,程序计数器;除了jvm管理的内存外,还有一个计算机的直接内存,java中通过特殊的办法也是可以操作到的
-
程序计数器:简单理解就是记录程序执行的行号,当线程切换回来时知道从哪一行接着执行,而不用从头开始,它只负责java程序的计数,如果执行的是native方法,那么这个计数器的值则为undefined。程序计数器这个区域是非常小的,是不会发生内存溢出的。
-
虚拟机栈:线程私有的,生命周期和线程是一样的,描述的是java方法执行的内存模型,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量,操作数栈,动态链接,方法出口等信息
- 动态链接:一些类的真实地址的在虚拟机栈中是用符号代替的比如Person p;p.eat这个p在编译的时候是一个符号引用,在真正调用p的eat方法时,要通过这个p找到真实的对象
-
本地方法栈是服务于虚拟机的,是为虚拟机使用到的native方法服务
-
方法区属于共享区域:存储类信息,常量,静态变量
- 运行时常量池属于方法区一部分,用于存放编译生成的各种字面量和符号引用,内存是有限的,无法申请时抛出outofMemoryError
- 虚拟机栈执行过程 虚拟机栈执行流程.png
-
对象的创建:
-
检查类是否已经被加载:虚拟机遇到new指令时,首先检查这个指定的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符合引用代表的类是否已经被加载,解析和初始化,如果没有,执行相应的类加载
-
为新生的对象分派内存:类加载检查通过之后,会为新对象分配内存(内存大小在类加载完成后便可确认),此时如果堆内存时绝对规整的,那么使用指针碰撞,否则使用空闲散列,找到一块足够大的内存划分给对象实例
-
堆内存是否规整:主要是看GC回收器是否包含压缩或者整理功能,如果有,那么内存就比较规整,如果没有则表示不规整,创建对象的时候就要使用空闲散列的方式了
-
内存空间分配完后,会将整个空间都初始化为零值(不包括对象头)
-
接下来就是填充对象头,把对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息存入对象头
-
new指令之后再执行init方法,此时对象才算创建完成
-
-
对象的内存布局:对象头,实例数据,对齐填充
-
对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针,及对象指向它的类元数据的指针。当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?是存在锁对象的对象头中的。
-
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(经过15次的GC后就会进入老年代)、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。
-
实例数据:程序代码中定义的各种类型的字段内容(包含父类继承下来的和子类中定义的)
-
对齐填充:不是必然需要的,主要是占位,保证对象大小是某个字节的整数倍
-
对象的访问定位:使用对象时,通过栈上的reference数据来操作堆上的具体对象,由于java虚拟机只规定要执行一个对象的引用,而没有规定以何种方式去定位,所以对象的访问方式取决于虚拟机的实现,主流的方式有两种:
-
通过句柄访问,java堆中会分配一块内存作为句柄池,reference存储的是句柄地址
-
使用指针访问,reference中直接存储对象地址
-
比较:使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象移动(GC)时,只改变实例数据指针地址,reference自身不需要修改,直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销,如果是对象频繁的GC那么使用句柄好,如果是对象频繁的访问则是直接使用指针好。HotSpot(我们jdk自带的虚拟机)使用的是指针的方式。
-
对象存活有两种判断:(HotSpot用的是可达性算法)
-
引用计数:每次被使用时计数器加1,解除引用时计数器减1,当计数器为0时,对象就被释放
-
可达性算法:通过一系列的GC Roots对象为出发点,从这些节点出发所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的时候说明对象不可用
-
哪些可作为GC Roots对象呢?
-
虚拟机栈(栈帧中的局部变量)中引用的对象
-
方法区中类静态属性引用的对象
-
本地方法栈中JNI(即一般说的Native方法)引用对象
-
-
HotSpot可达性相关:
-
从GC Roots节点找引用链,可是现在很多应用的引用比较复杂,比如方法区就有数据百兆,如果逐个检查必然消耗很多时间
-
为了保证整个分析期间整个执行系统被冻结,而不产生新的引用(必须在安全点冻结下,只不过时间非常短,不然在这个期间如果有新的引用产生,结果就不准确了),会导致java执行线程停顿(stop the world)
-
为了解决上面两个问题枚举根节点使用一组OopMap的数据结构来存放对象引用,这个数据结构在类加载完成的时候就已经计算出来了,GC在扫描的时候就可以得知这些信息,从而降低了GC Roots时间及减少停顿时间
-
OopMap中的引用关系可能会变化,或者OopMap的指令太多,反而需要更多的空间,此时解决方案是OopMap会根据虚拟机选定的安全点(safePoint可以简单理解执行到哪一句),在这个安全点内去生成指令的OopMap,在GC的时候,驱使所有的线程都跑到最近的安全点,STW才发生,应用才停顿
-
对于挂起的线程来说,比如处于sleep或者block状态的是不能跑到安全点的,那么此时候解决方案就是增大安全域(safe Region),如果线程已经到达安全域,做一个标记,GC就不需要管这些线程
-
-
垃圾收集算法:
-
标记清除(此算法需要暂停整个应用STW):分为标记和清除两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象;其主要缺点为:第一效率不高,标记和清除的效率都不高,第二标记清除后产生大量不连续的内存碎片,如果此时有一个较大的对象需要分配较大的空间而找不到连续内存时,就又要触发GC。
-
标记整理(主要用在存活对象比较多的情况,不适用与复制==》老年代):和标记清除算法一样,也是先标记,但是标记完后,是让所有存活的对象都向一端移动,然后直接清除边界以外的内存
-
复制(主要用在存活对象比较少的情况===》新生代):复制的收集算法,它将可用的内存按容量划分为大小相等的两块,每次只是用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉
-
-
收集方式:
-
串行收集:使用单线程处理所有垃圾回收工作,实现容易,效率高,但是无法使用多处理器的优势,因此适合单处理机器,也可以用在小数据量(100M左右)情况下的多处理机器上
-
并行收集:使用多线程处理垃圾回收工作,速度快,效率高,理论上cpu数目越多,越能体现并行收集器的优势
-
并发收集:相对于串行收集和并行收集而言,前面两个进行垃圾回收工作时,需要暂停整个运行环境(STW),而只有垃圾回收程序在运行,因此系统在垃圾回收会明显的暂停,而且暂停时间会因为堆越大而越长
-
-
其他:
- GC执行过程 GC过程.png
- GC日志 GC日志.png