类加载子系统
类的加载过程
类加载子系统负责从文件系统或者网络中加载Class文件,至于它是否可以运行,则由Execution Engine决定
加载的类信息存放于一块称为方法区的内存空间,除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
加载Loading
1,通过一个类的全限定名称获取定义此类的二进制字节流
2,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3,在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
连接Linking
验证Verification
目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
主要包含四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证
准备Preparation
为类变量分配内存并且设置该类变量的默认初始值,即零值
这里不包含final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化
这里不会为实例变量分配初始值,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中
解析Resolution
将常量池内的符号引用转换为直接引用的过程
事实上,解析操作往往会伴随着JVM执行完初始化之后再执行
符号引用就是一组符号类描述所引用的目标,直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
初始化Initialization
初始化阶段就是执行类构造器方法<clinit>()的过程
此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
构造器方法中指令按语句在源文件中出现的顺序执行
<clinit>()不同于类的构造器(<init>())
若该类具有父类,JVM会保证子类的<clinit>()执行之前,父类的<clinit>()已经执行完毕
虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁
类初始化的时机:
创建类的实例;调用类的类方法;访问类或者接口的类变量,或者为该类变量赋值;使用反射方式来强制创建某个类或接口对应的java.lang.Class对象;初始化某个类的子类;直接使用java.exe命令来运行某个主类
类加载器的分类
作用:负责将.class文件加载到内存,并为之生成对应的java.lang.Class对象
类加载机制:
全盘负责:就是当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非使用另外一个类加载器来载入
父类委托:就是当一个类加载器负责加载某个Class时,先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
缓存机制:保证所有加载过的Class都会被缓存,当程序需要使用某个Class对象时,类加载先从缓存区中搜索该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存储到缓冲区
启动类加载器(引导类加载器,Bootstrap ClassLoader)
负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等
使用C/C++实现的,嵌套在JVM内部
用来加载Java的核心类库,用于提供JVM自身需要的类
并不继承自java.lang.ClassLoader,没有父加载器
加载扩展类和应用程序类加载器,并指定为他们的父类加载器
只加载包名为java、javax、sun等开头的类
扩展类加载器(Extension ClassLoader)
负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包
派生于ClassLoader类
父类加载器为启动类加载器
从java.rxt.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类,如果用户创建的JAR包放在此目录下,也会自动由扩展类加载器加载
应用程序类加载器(系统类加载器,AppClassLoader)
负责加载ClassPath路径下的类包,主要就是加载你自己写的那些类
派生于ClassLoader类
父类加载器为扩展类加载器
负责加载坏境变量classpath或系统属性java.class.path指定路径下的类库
程序中默认的类加载器,Java应用的类都是由它来完成的
用户自定义类加载器
负责加载用户自定义路径下的类包
为什么要自定义类加载器
隔离加载类
修改类加载的方式
扩展加载源
防止源码泄露
用户自定义类加载器实现步骤
通过继承java.lang.ClassLoader类并重写loadClass()方法或在findClass()方法中写类的加载逻辑
直接继承URLCLassLoader类
获取ClassLoader的途径
获取当前类的CLassLoader:clazz.getClassLoader()
获取当前线程的ClassLaoder:Thread.currentThread().getContextClassLoader()
获取系统的ClassLoader:CLassLoader.getSystemClassLoader()
获取调用者的ClassLoader:DriverManager.getCallerClassLoader()
双亲委派模式
Java虚拟机对class文件采用的时按需加载的方式,加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式
工作原理:
1,如果一个类加载器收到了类加载请求,他并不会自己先去加载,而是把这个请求委托给父类的加载器区执行
2,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
3,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载
优势:
避免类的重复加载
保护程序安全,防止核心API被随意篡改
沙箱安全机制
自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(tr.jar包中java\langString.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护
在JVM中表示两个calss对象是否为同一个类存在两个必要条件:
类的完整类名必须一致,包括包名
加载这个类的CLassLoader(指CLassLoader实例对象)必须相同
运行时数据区
方法区:(线程共享)
存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
方法区在JVM启动的时候创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机会抛出OOM异常
关闭JVM就会释放这个区域的内存
元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代最大的区别在于元空间不在虚拟机设置的内存中,而是是使用本地内存
类型信息:
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
1,这个类型的完整有效类名(全名=包名.类名);2,这个类型直接父类的完整有效名;3,这个类型的修饰符;4,这个类型直接接口的一个有序列表
方法信息:
1,方法名称;2,方法返回类型;3,方法参数的数量和类型;4,方法的修饰符;5,方法的字节码、操作数栈、局部变量表及大小;6,异常表(每个异常处理的开始位置、结束位置、代码处理在程序计数器中的编译地址、被捕获的异常类的常量池索引)
域信息:
JVM必须在方法区中保存类型的所有域(属性)的相关信息以及域的声明顺序,域的相关信息包括:域名称、域类型、域修饰符
non-final的类变量:
静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时你也可以访问它
全局常量:static final
被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了
运行时常量池:
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
JVM为每个已加载的类型(类或接口)都维护一个常量池,池中的数据项想数组项一样,是通过索引访问的
运行时常量池种包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用
运行时常量池和常量池
一个Java源文件中的类、接口、编译后产生一个字节码文件,而Java中的字节码需要数据支持,通常这种数据会很大,不能存在字节码里,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池
常量池,可以看做是一张表,虚拟机指令根据这张常量池表找到要执行的类名、方法名、参数类型、字面量等类型
方法区内部包含了运行时常量池,字节码文件内部包含了常量池
常量池中的引用是符号地址,运行时常量池中的是真实地址
运行时常量池相对于常量池具备动态性
垃圾回收:
这个区域的回收效果比较令人难以满意,尤其是类型的卸载,条件相对苛刻,但是这部分区域的回收有时又确实是必要的
方法区的垃圾收集主要回收:常量池中废弃的常量和不再使用的类型
StringTable的调整
jdk7中将StringTable放到了堆空间中,因为永久代的回收率很低,在full gc的时候才会触发,而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable的回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存
堆:(线程共享)
所有的对象实例以及数组都要在堆上分配
一个JVM实例只存在一个堆内存,堆是Java内存管理的核心区域
Java堆在JVM启动时被创建,其空间大小也就确定了,是JVM管理的最大一块内存空间
堆可以处于物理上不连续的空间,但在逻辑上它应该被视为连续的
所有的对象实例以及数组都应该在运行时分配在堆上
堆是GC执行垃圾回收的重点区域
Young年轻代:
Eden区和两个大小相同的Survivor区,在,Eden区变满时,触发Young GC(Minor GC),GC会将存活的对象移到Survivor区间中,(两个Survivor区间一个被使用,一个在垃圾回收时复制对象用),经过几次垃圾回收后,Survivor区间中依然存活的对象被移到Tenured老年代区间
Minor GC会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
Young GC(Minor GC)过程:
1. 在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。
2. 紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。
3. 经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。
4. GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
特殊:
提前转移到老年代:在触发Minor GC时,Survivor区满时,会将对象存入年老代
大对象直接进入老年代:对象超大,在Eden区放不下时,会启动Young GC,清空Eden后还放不下,会直接放入年老代
对象动态年龄判断:如果Suivivor区中一批对象的总大小大于Survivor空间的一半,年龄大于或等于这批对象年龄最大值的对象可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄
老年代空间分配担保机制:年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了 如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾, 如果回收完还是没有足够空间存放新的对象就会发生"OOM" 当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”
TLAB:
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在JVM中非常频繁,因此在并发坏境下从堆区中划分内存空间是线程不安全的,为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
从内存模型的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。多线程同时分配内存时,使用TABLE可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,即快速分配策略
尽管不是所有对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选,一旦对象在TLAB空间分配内存失败,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
Tenured老年代:
Tenured老年代区间主要保存生命周期比较长的对象,(Young年轻代区间中复制一定次数的对象会转移到Tenured老年代区间中),一般如果系统使用了application级别的缓存,缓存中的对象往往会被转移到这一区间中
Old GC(Major GC)过程:
目前,只有CMS GC会有单独收集老年代的行为
Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长
触发机制:老年代空间不足时,会先尝试Minor GC,如果之后空间还不足,则触发Major GC,如果Major GC后,内存还不足,就报OOM
Mixed GC:
混合收集,收集整个新生代以及部分老年代的垃圾收集,目前只有G1 GC会有这种行为
Full GC:
整堆收集,收集整个Java堆和方法区的垃圾收集
触发机制:调用System.GC()时,系统建议执行Full GC,但是不是必然执行;老年代空间不足时;方法区空间不足时;
System.gc():提醒JVM垃圾回收器执行gc,底层调用Runtime.getRuntime().gc(),显示触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存
System.runFinalization():强制调用使用引用的对象的finalize()方法
Perm永久代:(jdk1.7)
Perm永久代主要保存class,method,filed对象,这部分空间一般不会溢出
Metaspace元数据空间:(jdk1.8)
Metaspace所占用的内存空间不是虚拟机内部,而是在本地内存空间中
Vietual区:
最大内存和初始内存的差值
虚拟机栈:(线程私有)
每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧,对应着一次次Java方法调用
主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回
存储局部变量表,局部变量表存放了编译期可知长度的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,是对象在堆内存的首地址)。方法执行完,自动释放
栈是一种快捷有效的分配存储方式,访问速度仅次于程序计数器,JVM对Java栈的操作只有两个:方法执行时的入栈和方法结束后的出栈,对于栈来说不存在GC
每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在,在这个线程上正在执行的每个方法都各自对应一个栈帧,栈帧时一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
栈中可能出现的异常
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定,如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常
如果Java虚拟机栈可以动态扩容,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线城市没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常
每个栈帧中存储:
局部变量表
局部变量表也称为局部变量数值或本地变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用,以及returnAddress类型
局部变量表所需的容量大小是在编译期确定下来的,在方法运行期间是不会改变的
局部变量表,最基本的存储单元是Slot(变量槽),32位以内的类型只占用一个slot,64位的类型占用两个slot
局部变量表中存放编译器可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈(表达式栈)
操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈出栈操作来完成一次数据访问
动态链接(指向运行时常量池的方法引用)
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接
描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
方法返回地址(方法正常退出或者异常退出的定义)
存放该方法的pc寄存器的值
一个方法结束有两种方式:正常执行完成,出现异常退出;无论哪种方式退出,在方法退出后都返回到该方法别调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址,而异常退出时,返回地址是要通过异常表来确定
一些附加信息
本地方法栈:(线程私有)
本地方法栈与虚拟机栈所发挥的作用时非常相似的,其区别只是虚拟机栈位虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法服务
本地方法是使用C语言实现的
当某个线程调用一个本地方法时,他就进入了一个全新的并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限:本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,他甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
程序计数器:(线程私有)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
PC寄存器用来存储指向下一条指令的地址,即将要执行的指令代码,由执行引擎读取下一条指令
它是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域
在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法,程序计数器会存储当前线程正在执行的Java方法的JVM指令地址
它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
它是唯一一个在Java虚拟机规范中没有任何OOM情况的区域,也没有GC
执行引擎
执行引擎的任务是将字节码指令解释/编译为对应平台上的本地机器指令才可以(高级语言->机器语言)
执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头的元数据指针定位到目标对象的类型信息
从外观上看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果
解释器:
当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为对应平台的本地机器指令执行
JIT编译器:
虚拟机将源代码直接编译成本地机器平台相关的机器语言
本地方法接口
本地方法是一个Java的方法,它的具体实现是由非Java语言实现的
本地接口的作用是融合不同的编程语言为Java所用
String
String的String Pool是一个固定大小的Hashtable,如果放进String Pool的String非常多,就会造成Hash冲突严重,导致链表会很长,而链表长了之后直接会造成的影响就是当调用String.intern时性能会大幅下降
StringTable的调整
jdk7中将StringTable放到了堆空间中,因为永久代的回收率很低,在full gc的时候才会触发,而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable的回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存
字符串拼接:
如果拼接前后出现了变量,则相当于在堆空间中new String(),s1("a")+s2("b")的具体执行细节:StringBuilder s = new StringBuilder();s.append("a");s.append("b");s.toString();(toString()的调用,在字符串常量池中,没有生成"ab")
字符串拼接操作不一定使用StringBuilder,如果拼接符号左右两边都是字符串常量或常量引用,则使用编译期优化
intern():
判断字符串常量池中是否存在"str"这个常量值,如果存在,则返回常量池中"str"的地址,如果不存在,在在常量池中加载一份"str",并返回此对象的地址
jdk1.6:
将这个字符串对象尝试放入常量池,如果常量池中有,则不会放入,返回已有的常量池中的对象的地址;如果没有,会把此对象复制一份,放入常量池,返回常量池中的对象地址
jdk1.7:
将这个字符串对象尝试放入常量池,如果常量池中有,则不会放入,返回已有的常量池中的对象的地址;如果没有,会把对象的引用地址复制一份,放入常量池,返回常量池中的引用地址
Demo:
String s1 = new String("1"); //s1指堆中的new String("1") s1.intern(); //调用此方法之前,字符串常量池中已经存在了"1" String s2 = "1"; //s2指向字符串常量池中的"1" System.out.println(s1==s2); //jdk6:false jdk7/8:false System.out.println("---------------------"); String s3 = new String("1") + new String("1"); //s3变量记录的地址为:new String("11") //执行完上一行代码以后,字符串常量池中,不存在"11" s3.intern();//在字符串常量池中生成"11"。如何理解:jdk6:创建了一个新的对象"11",也就有新的地址。 // jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址 String s4 = "11"; //s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址 System.out.println(s3 == s4); //jdk6:false jdk7/8:true System.out.println("---------------------"); String s5 = new String("1") + new String("1");//new String("11") //执行完上一行代码以后,字符串常量池中,不存在"11" String s6 = "11"; //在字符串常量池中生成对象"11" String s7 = s5.intern(); //字符串常量池中有"11",返回已有的常量池中的"11"的地址 System.out.println(s5 == s6); //false System.out.println(s7 == s6); //true