赞
踩
JVM位置
在操作系统上
JVM整体结构
类加载器:将.class 字节码文件加载进内存中生成class对象
加载、链接、初始化
运行时数据区:方法区和堆是线程共享的;程序计数器、本地方法栈、虚拟机栈是独有的
执行引擎:包含 解释器、JIT编译器、垃圾回收器(GC)
本地方法接口
Java代码执行流程
Java程序------------(通过java编译器(又叫前端编译器))---------------->生成字节码文件(对应一个java类)
第一次编译,把文件编译成字节码文件
---->类加载器------>解释器和JIT编译器(执行器)
第二次编译,把字节码文件中的字节码编译成机器指令,同时将机器指令缓存起来
生命周期
启动,执行,退出
启动是通过引导类加载器创建一个初始化类来完成的
执行一个所谓的java程序的时候,真真正正在执行的是一个叫做java虚拟机的进程
程序结束或者遇到异常或错误而异常终止,也可以使用Runtime或System的exit()方法退出虚拟机
类加载器和类加载过程
加载阶段,链接阶段,初始化阶段
类加载器子系统负责从文件系统或者网络中加载Class文件
ClassLoader只负责class文件的加载,是否执行由execution Engine决定
加载的类信息存放在方法区中,方法区除了类的信息外,还会存放运行时常量池的信息,包括字符串字面量和数字常量
类的加载过程
① Loading(加载阶段)
通过一个类的全限定类目获取定义此类的二进制字节流
将字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的java.lang.Class对象
,作为方法区这个类的各种数据的访问入口
(重点:生成java.lang.Class 这个类的实例 是在加载阶段完成的)
② Linking(链接阶段):验证,准备,解析
验证阶段: 保证被加载类的正确性,不会危害虚拟机自身安全;字节码文件的开头均为 CAFE BABE
,如果出现不合法的字节码文件,那么将会验证不通过
准备阶段 为类变量(static变量)分配内存并且为类变量的默认赋值,即零值
(开辟空间,为static的成员变量进行默认赋值)
解析阶段:将常量池内的符号引用转换为直接引用的过程
初始化阶段:(执行类构造方法<client>,显示赋值即给静态代码块赋值)
当我们代码中包含static变量的时候,就会有clinit方法
若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁
Jclasslib的使用
凡是继承自ClassLoader
的类加载器都称为自定义类加载器
类加载器的分类
启动类加载器 bootstrap classloader
这个类加载器使用C/C++实现的,嵌套在jvm内部
没有父加载器
加载扩展类和应用程序类加载器,并指定为他们的父类加载器
Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
(resources、rt、sunrsasign、jsse、jce、charsets、jfr、classes)
扩展类加载器
Java语言编写,由ExtClassLoader实现
父加载器为启动类加载器
加载java.ext.dirs系统属性所指定的目录中加载类库,或jdk的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的jar放在此目录下,也会自动被扩展类加载器加载(ext目录)
应用程序类加载器(系统类加载器,AppClassLoader)
Java语言编写,由AppClassLoader实现
派生与ClassLoader类
父加载器为扩展类加载器
负责加载环境变量classpath或系统属性 java.class.path指定路径下的类库
这类加载是程序中默认的类加载器
通过CLassLoader#getSystemClassLoader()方法可以获取到该类加载器
用户自定义类加载器实现步骤:
1.通过基础抽象类ClassLoader
类的方式,实现自己的类加载器,以满足一些特性的需求
2.jdk1.2后建议把自定义的类加载逻辑编写在findCLass()方法中
3.在编写自定义类加载器时,如果没有太过复杂的需求,可以直接继承URLClassloader
类,这样可以避免自己编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁
(要么继承抽象类ClassLoader,重写findClass()方法
,要么直接继承URLClassloader
)
双亲委派机制
类加载器收到加载请求,向上委托,委托到引导类目录加载器进行加载,若父加载器加载不了就向下委托给子加载器进行加载
双亲委派机制优势
避免类重复加载
避免核心类被篡改
沙箱安全机制
类的主动使用和被动使用
在JVM中表示两个class对象是否为同一个类存在两个必要条件:
① 类的完整类名必须一致,包括包名
② 加载这个类的CLassLoader(指ClassLoader实例对象)必须相同
(即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但是加载的ClassLoader实例对象不同,这两个类对象也不是相等的)
具体举例 java.lang.String 异常 没有mian
主动使用七种情况
初始化一个子类,(要先调用其父类)
反射
调用类的静态方法,(包括静态代码块)
创建类的实例
主动使用被动使用区别:初始化阶段是否调用**clinit
**方法,调用了就是主动使用
初始化inishualation
执行类构造方法 (执行static方法)
若有父类先执行父类构造方法
并发情况下同步加锁
线程
当一个java线程准备好执行后,操作系统会创建本地线程,当java线程被执行完后,本地线程也会被回收
本地线程决定JVM是否终止,如果只有守护线程(非普通线程),而没有普通线程 那JVM就立刻终止
操作系统负责线程调度,将线程调度到CPU上,一旦本地线程初始化成功就执行线程的run()方法(操作系统相当于CPU和线程之间的桥梁)
Hotspot后台运行的线程主要有以下几类:
虚拟机线程
周期任务线程
GC线程
编译线程
信号调度线程
PC寄存器(程序计数器)
用来存储下一条指令的地址
,由执行引擎读取下一条指令
(用于存放下一条执行指令的地址(操作数栈),执行引擎去pc寄存器中读取)
既没有GC,也没有OOM
PC寄存器面试题
使用PC寄存器存储字节码指令地址有什么用?
为什么使用PC寄存器记录当前线程的执行地址呢?
CPU不停的切换进程
,需要通过PC寄存器来获取下一条字节码指令的地址
为什么PC寄存器被设定为线程私有的?
为了能 准确记录各个线程正在执行的字节码指令地址
并行:多个线程,多个CPU
串行:多个线程依次排队被一个CPU执行
并发:一个cpu不停地切换线程执行
虚拟机栈
主要特点:跨平台,指令集小,编译器容易实现
栈是运行时的单位,而堆是存储的单位
(基本数据类型在栈中, 引用数据类型(类、接口、数组)在堆中;
局部变量(基本数据类型、对象的引用地址)存在栈中,成员变量(属性)存储在堆中)
虚拟机栈是什么?
每个线程创建时都会创建一个虚拟机栈,其内部结构为一个个栈帧,一个栈帧对应这一次java方法的调用;
线程是私有的
虚拟机栈的生命周期
生命周期和线程一致。(虚拟机栈随着线程的创建而创建,随着线程的消亡而消亡)
栈的特点(优点):访问速度仅次于 pc寄存器;
jvm堆java栈的操作只有两个 入栈,出栈;
不存在垃圾回收的问题
设置栈内存大小
-Xss
栈中存储什么(栈的单位):栈帧
在执行的每个方法都对应的一个栈帧
执行引擎运行的所有字节码指令只针对当前栈帧进行操作,如果该方法调用了其他方法,对应的新栈帧就会被创建出来,放在栈帧的顶部,成为新的当前栈
不同的线程中所包含的栈帧是不允许互相引用的
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种就是抛出异常。不管是哪种方式,都会导致栈帧被弹出
一个栈帧的入栈对应这一个新的方法调用,一个栈帧的出栈对应一个方法的结束,如果有异常的话,会返回给栈帧的调用者,依次往下执行
局部变量表
、操作数栈
、动态链接(指向运行时常量池中相对应方法的引用)、方法返回地址、一些附加信息
又叫局部变量数组/本地变量表
定义为一个数字数组,主要用于存储方法参数
和定义在方法内的局部变量
不存在数据安全问题(不涉及线程共享数据
)
局部变量表所需要的容量大小是在编译期确定下来的
方法嵌套调用的次数由栈的大小决定。栈越大,方法嵌套调用次数就越多(栈帧大小决定局部变量表的大小)
局部变量表中的变量只在当前方法调用中有效。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
局部变量表:存储局部变量,和方法参数
变量槽(slot):局部变量表的最基本存储单位
局部变量表存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
在局部变量表中,32位以内的数据类型都只占1个变量槽,64位的数据类型(long,double)占用2个变量槽
,如果需要访问局部变量表中64位的局部变量值时,只需要使用它的前一个索引位置即可(long或者double)
如果当前栈帧是构造方法或实例方法(非static方法)创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排序
如果栈帧是非static方法或者构造方法会默认自带一个slot插槽,静态方法也有一个slot插槽
变量的分类:按照数据类型分:① 基本数据类型 ② 引用数据类型
8大基本数据类型:linking的prepare阶段会进行默认赋值
实例变量:随着实例的创建而在堆中分配内存空间,并赋值,随着实例被gc回收而销毁
局部变量:使用前必须(显示)赋值,否则编译无法通过
栈的性能调优,关系最为密切的部分就是局部变量表
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明新的局部变量变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
//变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;
}
局部变量 c 重用了局部变量 b 的 slot 位置
操作数栈
满足特殊条件的数组或者说是链表;但是操作数栈不能采用访问索引的方式进行数据访问的
栈帧中除了局部变量表之外,还包含一个先进后出的操作数栈有,也可称为表达式栈
操作数栈,在方法执行过程中,根据字节码指令,在栈中写入数据或提取数据,即入栈(push)/出栈(pop)
方法的执行过程
操作数栈,主要保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
栈中的任何一个元素都可以是任意的java数据类型
32字节的类型占用1个栈单位深度
64字节的类型占用2个栈单位深度(long,double)
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新pc寄存器中下一条需要执行的字节码指令
栈顶缓存技术
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为此,
将栈顶元素全部缓存在物理CPU的寄存器,以此降低对内存的读/写次数,提升执行引擎的执行效率
动态链接(指向运行时常量池的方法引用)
栈帧内部指向运行时常量池所属方法的引用
作用:将符号引用转化为调用方法的直接引用
常量池的作用:就是提供一些符号和常量,便于指令的识别
方法的调用 (编译期间不确定-->涉及到多态 用接口或者父类作为方法参数
)
将符号引用转换为调用方法的直接引用与方法的绑定机制相关
静态链接(早期绑定):被调用方法在编译期间已确定
动态链接(晚期绑定):被调用方法在编译期间无法确定,在运行期间绑定相关的方法
虚方法和非虚方法
非虚方法:编译期就确定了具体的调用版本,且运行时不可变,这样的方法称为非虚方法
虚方法:编译期间无法确定(可重写的方法)
静态方法、私有方法、final方法、实例化构造器、父类
方法都是非虚方法
(不涉及多态)
其他方法称为虚方法
invokestatic
和invokespecial
指令调用的方法称为非虚方法,其余的(final修饰的除外)
都成为虚方法
Invokevirtual(调用所有的虚方法)、 虚方法指令
(final或super修饰的除外) invokeinterface(调用接口方法)
动态调用指令:
Invokedynamic:动态解析
需要调用的方法,然后执行
动态类型语言和静态类型语言
二者区别是对类型的检查
是在编译期还是运行期间,动态语言是在运行,静态语言在编译期
静态语言判断变量自身
的类型信息;动态语言判断的是变量值
的类型信息
Java是一门静态语言,但因为lambda表达式的出现,java也算一门动态语言
(编译期间未确定类型信息)
方法重写的本质:
IllegalAccessError 调用方法的权限不够(编译期间);类不兼容的改变,说白了jar包冲突(运行期间)
AbstractMethodError 调用方法时未重写方法,导致调用的是个抽象方法
虚方法表:面向对象过程中,频繁使用动态分派,每次动态分派都影响性能,为此,在类的方法区建立了一个虚方法表(virtual method table)(非虚方法不会出现在表中)。使用索引表来代替查找
虚方法表在类加载的链接阶段被创建并初始化的
方法返回地址(方法出口)
调用pc寄存器中的值作为返回地址,调用该方法的下一条指令,让执行引擎执行
pc寄存器将下一条指令的地址值 交给方法返回地址 让执行引擎继续执行操作
方法的结束方式:
正常执行完成;出现异常,非正常退出
如果是非正常退出,返回地址是要通过异常信息表来确定,栈帧中一般不会保存这部分信息
(异常完成出口退出的不会给他的上层调用者产生返回值
)
正常退出有给他的上层调用者返回地址信息(字节码指令中有返回指令(return)),
非正常退出(异常)要去异常信息表中查找异常信息,且不会给他的上层调用者返回消息
方法区和堆空间存在GC和Error
虚拟机栈和本地方法栈存在Error不存在GC
PC寄存器不存在Error和GC
分配的栈空间越大越好吗? 越大只能让异常出现的晚一些,不可能不出现;其次栈空间分配多了,内存空间就少了
方法中定义的局部变量是否线程安全? ---->逃逸分析
本地方法接口
被native 修饰的方法被称为本地方法
,调用其他语言的接口(例如Thread)
JVM还不是个操作系统,必须依赖操作系统来实现,而操作系统都是用C/C++来写的,本地方法接口的作用就是如此
本地方法栈
也是线程私有的;可以设置本地方法栈的内存大小(在内存溢出方面是相同的)具体做用是在本地方法栈中登记native方法,由执行引擎执行执行
在hotspot jvm中本地方法栈和虚拟机栈合二为一了
当要执行本地方法栈中方法时,由执行引擎执行
除了引导类加载器是用C++写的,扩展类、应用程序类加载器及自定义类加载器都是直接或间接继承ClassLoader(或URLClassLoader)
堆
每个JVM实例对应一个堆空间
堆是逻辑上连续,物理上不连续的一个内存空间
所有的线程共享java堆,在堆中还可以划分为线程私有的缓冲区
(Thread Local Allocation Buffer ,TLAB)
TLAB线程缓存区是不共享的
几乎所有的数组和对象实例都是分配在堆中的
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或数组在堆中的位置。
方法结束后,堆中的对象不会马上被移除,在GC垃圾回收的时候才会被移除
堆是GC回收的重点区域
堆内存结构细分
现代垃圾收集器大部分都是基于分代收集理论设计的,堆空间细分为
新生区+老年区+ 永久代(1.8为元空间)——————>方法区
新生区:伊甸园区+幸存from和幸存to
配置新生代与老年代在堆结构的占比(一般情况下不会去修改这个比例的)
默认:-XX:NewRatio=2 ,表示新生代占1,老年代占2,新生代占整个堆的1/3
可以修改: -XX:NewRatio=4 ,表示新生代占1,老年代占4,新生代占整个堆的1/5
某些对象的生命周期比较长,此时建议增大老年区比例
几乎所有的
java对象都是在Eden区被new出来的
绝大部分的java对象的销毁都在新生代进行(IBM研究80%的对象都是“朝生夕死”的)
对象分配的过程
Minor GC触发 Eden区满了就会触发,Survivor区满了不会触发
当Minor GC触发后,会回收Eden区和Survivor区的垃圾对象(若Eden区的对象未被回收,自动进入survivor区)
Survivor区满了,Survivor区的对象
直接进入老年代
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to
关于垃圾回收:频繁在新生区收集,很少在老年区收集,几乎不在永久区/元空间收集
Minor GC、Major GC、Full GC
JVM在进行GC时,并非每次都对新生区、老年区;方法区 一起回收的,大部分时候回收的都是新生代
针对Hotspot VM的实现,它里面的GC按照回收区域分为两大类型:一种是部分收集(partial GC),一种是整堆收集(full gc)
部分收集:
新生代收集(minor GC /young GC)
老年代收集(major GC/old GC)
目前只CMS GC会单独收集老年代
混合收集(mixed GC):收集整个新生代以及部分老年代的垃圾收集 ,
目前只有G1 GC
会有这种行为
整堆收集 Full GC:收集整个java堆和方法区的垃圾收集。
年轻代GC(Minor GC)触发机制:
当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden区满,SUrvivor满不会引发GC(每次Minor GC 会清理年轻代(Eden,Survivor)的内存)
因为java对象大多数都是朝生夕灭的特性,所以Minor GC分层频繁,一般回收速度也比较块。
Minor GC会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
老年代GC(Major GC/Full GC)触发机制:
出现Major GC,经常会伴随至少一次的Minor GC
(但非绝对的,例如parallel 收集器的收集策略就是直接进行Major GC)
(在老年代空间不足时,会先尝试触发Minor GC 。如果之后空间还不足,则触发Major GC) 如果Major GC后,内存还不够,就OOM了)
Major GC 的速度一般会比Minor GC慢10倍以上
Full GC触发机制
1.调用System.gc() 时,系统建议执行Full GC,但是不必然执行
2.老年代空间不足
3.方法区空间不足
4.通过Minor GC后进入老年代的平均大小大于老年代的可用内存
5.由Eden、Survivor0向Survivor1区复制时,对象大小大于 To Space可用内存,则把该对象转存到老年代,且老年代的内存小于该对象大小 (Survivor区满了后,old区放不下)
(full GC尽量避免)
内存分配策略
优先分配到Eden
大对象直接分配到老年代(需要一个连续的内存空间对象,说白了Eden区放不下了)
(比较长的数组或者字符串,Eden区塞不下
)
(尽量少创建大对象(空间不足以放下这些大对象就会GC,进而STW)
)
长期存活的对象分配到老年代
动态年龄判断
(同一年龄的所有对象大于Survivor区的一半
,将大于或等于该年龄的对象直接进入老年代)
空间分配担保
-XX:HandlePromotionFailure
TLAB(为对象分配内存Thread Local Allocation Buffer)
(给线程分配个缓冲区)
堆区是线程共享的,在并发环境下从堆区中划分内存空间是线程不安全
什么是TLAB?
内存分配时,JVM为每个线程分配了一个私有缓存区域
,包含在Eden内
TLAB仅占整个Eden区的1%
开启TLAB -XX:UseTLAB
(默认情况下是开启的)
-XX:TLABWasteTargetPerent 修改TLAB的空间大小
尽管不是所有的对象实例都能在TLAB上成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
一旦对象在TLAB空间分配失败,JVM会尝试通过加锁机制来确保操作数据的原子性
,从而直接在Eden区中分配内存。
堆空间都是共享的吗? TLAB
空间分配担保机制(jdk7 开始默认都是开启的)
-XX:HandlePromotionFailure:是否设置空间分配担保
Minor GC前,jvm会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
如果大于,minor GC
如果小于,则查看虚拟机是否开启担保机制(默认是开启的)
如果开启,那么会继续检查老年代最大连续可用空间是否大于历次晋升到老年代的对象平均大小
如果大于Minor GC
如果小于 full GC
如果没开启,直接full GC
只要老年代的连续空间大于新生代对象总大小
或者历次晋升的平均大小
就会进行minor GC,否则就进行Full Gc
栈上分配
逃逸机制
当一个对象在方法当中被定义后,对象只在方法内部使用
(未发生逃逸机制,满足栈上分配)
当一个对象在方法中被定义后,他被外部方法所引用,则认为发送了逃逸。
例如作为调用参数传递到其他地方中。
如何快速的判断是否发生了逃逸分析,就看new的对象实体是否在方法外被调用。
只在方法内new 对象实例,在方法内使用,未返回
Jdk7开始默认开启逃逸机制
开发中能使用局部变量的,就不要使用在方法外定义
优化代码
栈上分配
同步省略
分离对象或标量替换
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,如果对象没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收
同步省略(锁消除)
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
线程同步的代价是相当高的,同步的后果是降低并发性和性能
在动态编译同步块的时候,JIT编译器可以结束逃逸分析来判断同步块锁使用的锁对象是否只能够被一个线程服务而没有被发布到其他线程
。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。
(在方法中的对象只能被一个线程访问,不涉及并发,没必要使用同步代码块)
(对象作为局部变量的话不考虑同步机制)
分离对象或标量替换
将聚合量拆分成标量 (将对象打成基本数据类型分配在栈上)
Java对象就是聚合量,因为他可以分解成为其他聚合量和标量
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会吧这个对象拆解成若干个成员变量替换(这就是标量替换)
堆是对象分配的唯一选择? 是的,对象实例都是创建在堆上
Oracle的hot spot虚拟机目前也没默认应用栈上分配,但是有应用标量替换
方法区
虚拟机栈栈帧中局部变量表的对象引用指向 堆中的对象实例 ,对象实例指向方法区中的类型数据(类信息 Class)
方法区在逻辑上是属于堆的一部分,但是一些简单的实现可能不会选择去进行垃圾收集或者压缩,对于HotspotJVM而言,方法区还有一个别名叫做非堆,目的就是要和堆分开
所以,方法区是一块独立于堆的内存空间 (设置堆空间的大小不会影响方法区)
方法区和堆一样都是线程共享的
方法区在jvm启动的时候被创建,并且它的实际物理内存空间和java堆区一样可以不连续
方法区的大小与堆空间一样,可以选择固定大小或者是可扩展。
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,会导致方法区的溢出,虚拟机同样会抛出内存溢出错误:OOM
(例如加载大量的第三方jar;tomcat部署的工程过多;大量生成动态的反射类
)
关闭JVM就会释放这个区域的内存
方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。
Jdk7及之前
-XX:PermSize 设置初始分配空间,默认是20.75M
-XX:MaxPermSize 设置永久代最大可分配空间 32位电脑默认是64M,64位电脑默认是82M
Jdk8及之后
-XX:MetaspaceSize 默认约等于21M(实际算出来为20.79M)
-XX:MaxMetaspaceSize 默认为-1,即没有限制
正常开发情况下设置 -XX:MetaspaceSize
-XX:MetaspaceSize :设置初始的元空间大小。对于一个64位的服务器端JVM来说
默认的大小为21M,这是初始的最高位线,一旦触及这个水平线,full GC就会触发
,然后水平线会重置。新的水平线取决于GC后释放了多少元空间。如果释放空间不够,那么在不超过MaxMetaspaceSize 时,适当的提高该值。如果释放空间过多,则适当降低该值。
如果测试的水平线过低,上述水平线调整情况会发生好多次。通过垃圾回收器的日志可以观察到full gc多次调用。为了避免频繁地GC,建议
-XX:MetaspaceSize 设置为一个相对较高的值
方法区内存结构
.java源文件 编译成 .class字节码文件 通过类加载器 加载进运行时数据区
类信息本身存放到方法区中
方法区存储什么?类型信息(类型基本信息,方法信息,域信息),常量,静态变量,
JIT编译后的代码缓存
类型信息:
对每个加载的类型(类Class、接口interface、枚举Enum、注解annotation)在方法区中存
储以下类型信息
1这个类的位置类名(全名=包名.类名)
2这个类直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
3这个类的修饰符(public,abstract,final的某个子集)
4这个类直接接口的一个有序列表
域(field)信息
域信息包括:域名称,域类型,域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
方法(method)信息
方法名称,方法的返回类型(或void),方法参数的数量和类型(按顺序),方法的修饰符,
方法的字节码、操作数栈、局部变量表及大小,异常表(abstract和native方法除外)
全局常量:static final (面试别人的坑)
被声明为final的类变量的处理方法则不同,每个全局变量在编译期(prepare阶段)就被分配了
(面试别人的坑)
运行时常量池
字节码指令中的常量池(Constant pool)运行时加载进常量池中就是运行时常量池了
为什么要有常量池?
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池。这个字节码包含了指向常量池的引用。在动态链接的时候将运行时常量池的符号引用转成直接引用。
#xx 都是在常量池中加载的
常量池的引用类似于炒菜时的基本原料;代码就类似于菜(方法类似菜,方法里面的内容,都是原料,都在常量池中)
几种常量池内存储的数据类型包括:数据值、字符串值、类引用、字段引用、方法引用
常量池:存放编译期生成的各种字面量和符号引用,运行时转化为直接引用
小结:常量池、可以看做是一张表,表中存放编译期生成的各种字面量和符号引用
,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量
等类型。
运行时常量池
运行时常量池是方法区的一部分
常量池是class文件的一部分,用于存放编译期生成的各种字面量和符号引用(加载后转化为直接地址或者叫直接引用),这部分内容在类加载后存放到方法区的运行时常量池中 (常量池在类加载后,存入运行时常量池中)
加载类和接口到虚拟机后,jvm就会创建对应的运行时常量池
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组都是通过索引访问的
(每个.class 文件加载进jvm时,jvm会创建相对应的常量池。说白了,将每个.class文件的常量池加载进运行时常量池中)
运行时常量池,相对于class文件中的常量池的另一重要特征就是:具备多态性
(字符串拼接)
例如: String.intern()
面试考点:jvm内存空间在版本演进过程中存在那些变化吗
方法区演进细节
只有Hotspot才有永久代的概念, IBM、JRocket等不存在永久代的概念
1.6及之前 | 有永久代,静态变量存放在永久代上 |
---|---|
1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量被移除出永久代,保存在堆中 |
1.8及之后 | 无永久代,类信息、字段、方法常量保存在本地内存的元空间,但字符串常量池、静态变量人在堆 |
永久代为什么要被元空间替换?
1永久代设置空间大小不好确定
,很容易OOM
太小容易导致full GC 进而STW
而元空间不存在虚拟机中,使用的是本地内存
,最大空间为-1(无限大)。默认情况下,元空间大小仅受本地内存的限制
2对永久代调优比较困难
为什么静态变量和字符串常量池StringTable要移到堆中
StringTable为什么要移到堆中?
永久代只有full gc时才会回收,而full gc只有老年代永久代空间不足时才会触发
导致StringTable回收效率不高。而开发中会有大量的字符串被创建,回收效率低,导致永久代空间不足,放到堆里,能及时被回收。
** 静态变量存在哪里?**
1、静态引用对应的对象实体(也就是这个new byte[1024 * 1024 * 100])始终都存在堆空间中。
2、只是那个变量(相当于下面的arr变量名)在JDK6,JDK7,JDK8存放位置中有所变化
变量(名)
存放在哪里?
Jdk7及其之后的版本,Hotspot把静态变量与类型在java语言一端的映射Class对象存放在一起,存储于Java堆之中
方法区的垃圾回收
有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载
)。
方法区的垃圾回收主要回收两部分内容:
常量池中废弃的常量和不在使用的类型
方法区常量池中主要的两大类常量:字面量
和符号引用
字面量比较接近java语言层次的常量概念,如文本字符串、被声明为final的常量值等
而符号引用则属于编译原理方面的概念,包括下面三类常量:
类和接口的权限的类名、字段的名称和描述符、方法的名称和描述符
Hotspot虚拟机对常量池的回收策略是很明确的,只要常量池的常量没有被然任何地方引用,就可以被回收
(回收废弃常量和java堆中的对象非常类似)
判断一个类型是否被回收(不再使用) 条件比较苛刻,需同时满足下面三个条件
该类的实例都已经被回收了(类实例的对象 不再被使用)
加载类的加载器已经被回收了
该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访该类的方法(反射不在被使用)
(无实例对象,无类加载器,无反射的使用)
方法区要不要垃圾回收? 可以要可以不要,《java虚拟机规范》中没有明说
对象的实例化内存布局与访问定位
创建对象的方式
1 new
直接new
单例模式中的静态方法
XXXBuilder/XXXFactory的静态方法
2 Class的newInstance() 反射的方式,只能调用空参的构造器,权限必须是public
3 Constructor的newInstance(XXX) 反射的方式,可以调用空参、带参构造器,权限没要求
4 使用clone() 不调用任何构造器,当期类需要实现cloneable接口,实现clone()
5 使用反序列化 从文件中、网络中获取一个对象的二进制流
6 第三方库Objenesis
对象创建的步骤
加载类源信息
、为对象分配内存空间、处理安全并发问题、属性初始化赋值、设置对象头、属性的 显示初始化、代码块、构造器赋值(init())
1 判断对象对应的类是否加载、链接、初始化
(方法区中是否存在相对应的类)
(虚拟机遇到一条new指令,首先去检查这个指令的参数能否在metaspace的常量池
中定位到一个类的符号引用
,并检查这个符号引用代表的类是否已经内加载、解析、初始化
。[即判断这个类是否存在
]。如果没有,那么在双亲委派模式
下,使用当前类加载器以ClassLoader+包名+类名为key进行查找对应的.class文件
。如果没找到文件,则抛出CLassNotFoundException异常,如果找到,则进行类加载
,并生成相对应的Class类对象)
2 为对象分配内存
(取决于垃圾收集器是否有压缩整理功能)
(首先计算对象占用空间的大小
,接着在堆中划分一块内存
给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。)
指针碰撞(内存规整) Serial、ParNew垃圾回收器
空参列表(内存不规则) CMS垃圾回收器—标记清楚算法
3 处理并发安全问题
TLAB 为每个线程预先分配一块本地线程缓冲区
采用CAS配上失败重试保证原子性
4 初始化分配到的空间 (属性的默认初始化值)
所有属性设置默认值
给对象的属性赋值操作: ①属性的默认初始化 ②显示初始化 ③代码块初始化④构造器初始化
5 设置对象头
6 执行init方法进行初始化(显示初始化,包括③②)
(类构造器对应的方法,静态属性初始化方法)
== 对象内存布局 (对象在堆空间的布局)==
对象头
、实例数据、对齐填充
对象头:运行时元数据、类型指针
运行时元数据:
哈希值(hashcode): 局部变量表中引用变量指向堆中的地址值
GC分代年龄
锁状态
类型指针 -----指向元数据InstanceKclass,确定该对象所属的类型
(说明:如果是数组,还要记录数组的长度)
实例数据(Instance Data) (类的成员变量,以及父类的成员变量)
对象存储的有效信息,包括程序代码中定义的各种类型的字段
(包括父类继承和自己本身拥有的字段)
对齐填充 不是必须的,没有特别含义,仅仅起到占位符的作用
对象访问定位
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例?
定位,通过栈上reference访问
对象访问方式主要有两种:句柄访问、直接指针(虚拟机默认的)
优点:栈帧的本地变量表中变量操(slot)的reference地址稳定,对象被移动(垃圾收集时移动移动对象很普遍)时只改变句柄中的实例对象地址即可,reference地址不需要被修改
优点:节省内存空间
(Hotspot默认的方式)
** 直接内存 ** (Jdk1.8元空间)
不是虚拟机运行时数据区的一部分,也不是jvm规范定义的一部分
直接内存是在java堆外的、直接向系统申请的内存区间
来源于NIO,通过存在堆中的DirectByteBuffer操作native内存
通常,访问直接内存的速度会优于java堆。即读写性能高。
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
Java的nio库允许java程序使用直接内存,用于数据缓冲区
有可能导致OutOfMemoryError异常 (nio的OOM异常是监控器中堆内存未满状况下)
由于直接内存(nio)在java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,java堆和直接内存的总和依然受限于操作系统给出的最大内存。
缺点
分配回收成本较高
不熟jvm内存回收管理
直接内存带下可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致
执行引擎
概述:
执行引擎是java虚拟机核心的组成部分之一。
“虚拟机”是一个相当于“物理机”的概念,他们都有执行代码的能力,区别是物理机的执行引擎是建立在处理器、缓存、指令集合操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的
,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行不被硬件直接支持的指令集格式
Jvm的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统上
,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被jvm所识别的字节码指令、符号表,以及其他辅助信息。
要想让一个java程序运行起来,执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。(执行引擎就相当于一个翻译官,将(高级语言)字节码指令翻译为(机器语言)机器指令,由操作系统执行
)
(jvm的任务就是加载字节码文件,由执行引擎执行变成机器指令,让操作系统执行)
工作过程
执行引擎在执行的过程中依赖于PC寄存器
每执行完一项指令操作后,pc寄存器就会更新下一条执行指令的地址
执行引擎执行过程中,会通过局部变量表的对象引用准确定位到存储在堆中的对象实例信息,以及通过对象头中的类型指针定位到对象的类源信息
执行引擎将字节码指令转化为机器指令,让cpu执行 (作用)
从外观上来看,所有的java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码的二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果
Java代码编译和执行的过程
大部分的程序代码转化为物理机的目标代码或虚拟机能执行的指令集之前,都需要经历这些步骤
Javac—> 编译成 抽象语法树
什么是解释器,什么是JIT编译器
解释器:当java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行
,将每条字节码文件的内容“翻译”为对应平台的本地机器指令执行。(将每条字节码翻译成机器指令)
JIT编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言(机器指令)(将源代码直接变成机器指令)
为什么java是半编译型半解释型语言
Jdk1.0时代只有解释器,只能解释执行,后来java也发展出直接生成本地代码的编译器。
执行引擎执行字节码文件时既可以使用解释器,也可以使用编译器
Jit编译器将字节码指令翻译成机器指令后,能进行缓存,执行效率更高
机器码
各种用二进制编码方式表示的指令,叫做机器指令码。开始就用机器指令码来编写程序,这就是机器语言。
机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用机器指令码编程极容易出错。
和其他语言相比,机器语言(机器字节码)的执行速度最快
机器指令和CPU密切相关,不同种类的CPU所对应的机器指令也不相同
(二进制编码)
指令
由于机器码由0和1组成的二进制序列,可读性太差,于是出现了指令
指令就是把机器码中特定的0和1序列,简化成对应的指令,可读性好
由于不同的硬件平台,执行同一操作,对应的机器码可能不同,所以不同的硬件台的同一种指令,对应的机器码也可能不同
指令集
不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称为对应平台的指令集。
如常见的
X86指令集,对应的是X86架构的平台
ARM指令集,对应的是ARM架构的平台
汇编语言
由于指令的可读性还是太差,于是又发明出了汇编语言。
在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址
在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换为机器指令
由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码
,计算机才能识别和执行
高级语言
为了是计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码
。完成这个过程的程序就叫做解释程序或编译程序。
二者关系
高级语言转换成汇编语言,再转换成机器指令,最后由cpu执行
字节码
字节码是一种中间状态(中间码)的二进制代码(文件)
,比机器码更抽象,需要直译器转译后才能成为机器码
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关
。
字节码的实现方式是通过编译器和虚拟机。编译器将源码编译成字节码,特定平台上的虚拟机将字节码转译成可直接执行的指令
字节码的典型应用为java bytecode。
(字节码是可以在不同平台上运行)
解释器
为什么要有解释器?
JVM设计者们的初衷仅仅只是单纯地为了满足java程序实现跨平台特性
,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法(也就是产生了一个中间产品字节码)。
解释器工作机制(工作任务)
解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件的内容“翻译”为对应平台的本地机器指令执行
。
当一条字节码指令被解释执行完成后,接着再根据PC寄存器记录的下一条需要被执行的字节码指令执行解释操作。
分类:字节码解释器,模板解释器
字节码解释器
在执行时通过纯软件代码模拟字节码的执行,效率十分低下。
模板解释器
将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而提升解释器的性能。
JIT编译器(运行效率比解释器块(直接将源代码翻译成机器指令,无需通过javac变成字节码指令))
HotSpot JVM的执行方式
Hotspot VM采用的是解释器与编译器并存的架构
,在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。(目的是各种协作取长补短
)
(具体是解释执行还是即时编译,要看Hotspot的判断标准)
Jit的优势:比解释执行速度快,已经将代码翻译成机器指令且缓存了,比解释器逐行翻译的效率要高很多
解释器的优点:当程序启动后,解释器可以马上发挥作用,响应速度快,而jit想要发挥作用,将字节码翻译成机器指令然后执行,这需要一定的时间,但翻译成机器指令后,jit编译器执行效率高。
Hotspot JVM的执行方式
当虚拟机启动的时候,解释器可以首先发挥作用
,而不必等待jit编译器全部编译完成后再执行,这样可以省去很多不必要的编译时间。随着程序运行时间的推移,jit编译器逐渐发挥作用,根据热点推测功能,将有价值的字节码编译为本地机器指令
,以换取更高的程序执行效率
编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间,但编译为本地代码后,执行效率高
JIT编译器
相关概念解释
① Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器
(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程。
(将java文件转化为字节码文件的过程)
典型的前端编译器:SUN的javac、eclipse JDT中的增量式编译器(ECJ)
② 也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。
典型的JIT编译器:HotSpot的C1、C2编译器
③ 还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程。(可能是后续发展的趋势)
热点探测及探测方式
是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率
而定。
需要被编译为本地代码的字节码,也被称为“热点代码”,JIT编译器在运行时会针对“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升java程序的执行性能。
一个被多次调用的方法
,或者是一个方法体内部循环次数较多的循环体
都可以被称之为“热点代码
”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On StackReplacement)编译。
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。
方法调用计数器
这个计数器用于统计方法被调用的次数,他的默认阙值在client模式下
是1500次
,在server模式下
是10000
次。超过这个阙值,就会触发JIT编译。(64位电脑默认server模式)
这个阙值可以通过虚拟机参数 -XX:CompileThreshold
来人为设定。
当一个方法被调用时,查看方法区(元空间)中是否存在相对应的jit缓存
如果存在,则优先使用编译后的本地代码来执行
如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。
如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。(编译好后留下jit缓存,存放在元空间当中)
如果未超过阈值,则使用解释器对字节码文件解释执行
热度衰减
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译
,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)(半衰周期是化学中的概念,比如出土的文物通过查看C60来获得文物的年龄)
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay
来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样的话,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
另外,可以使用-XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒。
回边计数器
回边计数器的作用是统计一个方法体中循环代码的执行次数
,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。
缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。
(cmd模式下运行或者 IDEA中的参数)
-Xint
:完全采用解释器模式执行程序;
-Xcomp
:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行
-Xmixed
:采用解释器+即时编译器的混合模式共同执行程序。
HotSpot VM中JIT分类
在Hotspot VM中内嵌两个jit编译器,分别为Client Compiler(c1)和Server Compiler(c2)
-client:指定java虚拟机运行在Client模式下,并使用c1编译器;
C1编译器会对字节码进行简单和可靠优化,耗时端。以达到更快的编译速度
-server:指定java虚拟机运行在Server模式下,并使用C2编译器。
C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。
在不同的编译器上有不同的优化策略,
C1(client)编译器上主要有方法内联,去虚拟化、元余消除。
方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
去虚拟化:对唯一的实现樊进行内联
冗余消除:在运行期间把一些不会执行的代码折叠掉
C2(server模式,64位电脑默认是server模式,无法更改,idea运行参数倒是可以更改执行方式)的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:
标量替换:用标量值代替聚合对象的属性值
栈上分配:对于未逃逸的对象分配对象在栈而不是堆
同步消除:清除同步操作,通常指synchronized
分层编译策略
:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。
在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。
一般来说,jit编译出来的机器码性能比解释器高;
c2编译器启动比c1编译器慢,系统稳定执行后,c2编译器执行速度比c1编译器快
Graal 编译器
自JDK10起,HotSpot又加入了一个全新的即时编译器:Graal编译器
编译效果短短几年时间就追平了C2编译器,未来可期(对应还出现了Graal虚拟机,是有可能替代Hotspot的虚拟机的)
目前,带着实验状态标签,需要使用开关参数去激活才能使用
-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler
String的基本特性
String:字符串,使用一对 “” 引起来表示
String s1 = “atguigu” ; // 字面量的定义方式
String s2 = new String(“hello”); // new 对象的方式
String被声明为final的,不可被继承
String实现了Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小
String在jdk8及以前内部定义了final char
value[]用于存储字符串数据。JDK9时改为byte[]
为什么 JDK9 改变了 String 的结构
String类的当前实现将字符存储在char数组中,每个字符使用两个字节(16位)。
从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁字符(Latin-1)。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部char数组中有一半的空间将不会使用,产生了大量浪费。
之前 String 类使用 UTF-16 的 char[] 数组存储,现在改为 byte[] 数组 外加一个编码标识存储。该编码表示如果你的字符是ISO-8859-1或者Latin-1,那么只需要一个字节存。如果你是其它字符集,比如UTF-8,你仍然用两个字节存
结论:String再也不用char[] 来存储了,改成了byte [] 加上编码标记,节约了一些空间
同时基于String的数据结构,例如StringBuffer和StringBuilder也同样做了修改
String:代表不可变的字符序列。简称:不可变性。
当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池中是不会存储相同内容的字符串的
String 的String Pool 是一个固定大小的HashTable ,默认长度为1009(1.6)
。如果放进String Pool的String 非常多,就会造成hash冲突严重,从而导致链表很长,而链表长了后会造成的影响就是当调用String.intern时性能会下降
使用 -XX:StringTableSize可设置StringTable的长度
在jdk1.6中StringTable是固定的,就是1009的长度,如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求
在jdk1.7中,StringTable的长度默认是60013,
Jdk1.8开始1009是设置的最小值
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种
。
直接使用双引号声明出来的String对象会直接存储在常量池中。
比如:String info=“hello”;
如果不是用双引号声明的String对象,可以使用String提供的intern()方法。
Jdk6及以前,字符串常量池存放在永久代。
Jdk7 中oracle的工程师对字符串池的逻辑做了很大的改变,将字符串常量池的位置调整至java堆内
所有的字符串都保存在堆(heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以。
字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在jdk7中使用String.intern()
Jdk8元空间,字符串常量在堆中
永久代默认空间较小
永久代垃圾回收频率较低,极容易发送full GC,产生stw,或OOM
堆空间足够大,字符串能及时被回收
Java语言规范要求完全相同的字符字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例
分析运行时内存(foo() 方法是实例方法,其实图中少了一个 this 局部变量)
常量与常量的拼接结果在常量池,原理是编译器优化
常量池中不会存在相同内容的常量
只要其中有一个是变量,结果就在堆中
。变量拼接的原理是StringBuilder
toString底层约等于new了一个新的String对象,并将字符串赋值给String对象
字符串破解操作不一定使用的是StringBuilder,如果拼接符号左右两边都是字符串常量或者常量引用(被final修饰的),仍然使用编译器优化,即非StringBuilder方式
针对final修饰的类、方法、基本数据类型、引用数据类型的量的结构时,能使用final的时候建议使用
如果拼接的结果调用intern(),则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),新new了String对象
Intern():判断字符串常量池中是否存在相对应的值,存在,返回相对应的地址
不存在,则在常量池中加载一份,并返回次对象地址
Intern是一个native方法,调用的是底层C的方法
字符串常量池最初是空的,用String类私有的维护。调用intern方法时
,如果池中已经包含了由equals(object)方法确定的与该字符串内容相等的字符串
,则返回池中的字符串地址。否则,该字符串对象将对象添加到池中,并返回对该字符串对象的地址
如何保证变量s指向的是字符串常量池中的数据呢?
方式1:String s=‘a’;
方式2:String s= new String(‘a’).intern();
String s= new StringBuilder(‘a’).toString().intern();
如果不是用双引号声明的String对象,可以使用String提供的intern方法:intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中,返回字符串常量池中的地址
new String(“ab”)会创建几个对象? 2个
new #2 <java/lang/String>:在堆中创建了一个 String 对象;
ldc #3 <ab> :在字符串常量池中放入 “ab”(如果之前字符串常量池中没有 “ab” 的话)
new String(“a”) + new String(“b”) 会创建几个对象?
对象1: new StringBuilder()
对象2: new String(‘a’)
对象3: 常量池中的‘a’
对象4: new String(‘b’)
对象5: 常量池中的‘b’
对象6:toString()中 new String(“ab”)
强调,toString方法中的字符串在字符串常量池中没有生成
(intern()倒是在字符串常量池中生成了)
String s = new String(“1”);
s.intern();//调用此方法之前,字符串常量池中已经存在了"1"
String s2 = “1”;
System.out.println(s == s2);//jdk6:false jdk7/8:false
1、s3变量记录的地址为:new String(“11”)
2、经过上面的分析,我们已经知道执行完pos_1的代码,在堆中有了一个new String(“11”)
这样的String对象。但是在字符串常量池中没有"11"
3、接着执行s3.intern(),在字符串常量池中生成"11"
3-1、在JDK6的版本中,字符串常量池还在永久代,所以直接在永久代生成"11",也就有了新的地址
3-2、而在JDK7的后续版本中,字符串常量池被移动到了堆中,此时堆里已经有new String(“11”)了
出于节省空间的目的,直接将堆中的那个字符串的引用地址储存在字符串常量池中。没错,字符串常量池中存的是new String(“11”)在堆中的地址
4、所以在JDK7后续版本中,s3和s4指向的完全是同一个地址。
String s3 = new String("1") + new String("1");//pos_1
s3.intern(); //s3等于用StringBuilder的append(),然后用toString() 生成一个String对象,此时新new的String对象字符串,在字符串常量池中没有生成
String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true
/*如何保证变量s指向的是字符串常量池中的数据呢?
* 有两种方式:
* 方式一: String s = "shkstart";//字面量定义的方式
* 方式二: 调用intern()
* String s = new String("shkstart").intern();
* String s = new StringBuilder("shkstart").toString().intern() */
String s5 = "shkstart";
String s6 = new String("shkstart").intern();
String s7 = new StringBuilder("shkstart").toString().intern();
System.out.println(s5 == s6); jdk7/8:true
面试题的拓展
//执行完下一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
String s3 = new String("1") + new String("1");//new String("11")
//在字符串常量池中生成对象"11",代码顺序换一下,实打实的在字符串常量池里有一个"11"对象
String s4 = "11";
String s5 = s3.intern();
// s3 是堆中的 "ab" ,s4 是字符串常量池中的 "ab"
System.out.println(s3 == s4);//false
// s5 是从字符串常量池中取回来的引用,当然和 s4 相等
System.out.println(s5 == s4);//true
.intern()诺字符串常量池中未存在,则生成,已存在则返回地址,将引用地址赋值给新对象,所以s3==s4//false)
总结String的intern()的使用:
Jdk1.6中,将这个字符串对象尝试放入字符串常量池。
如果常量池中有,则并不会放入。返回已有的在字符串常量池中对象的地址
如果没有,会吧此对象复制一份
,放入字符串常量池中,并返回字符串常量池的对象地址
Jdk1.7起,将这个字符串对象尝试放入字符串常量池。
如果字符串常量池中有,则并不会放入。返回已有的字符串常量池中的对象的地址
(若未赋值给新的对象,则原对象的地址值依旧为堆空间中的,具体参考上面的s3==s4)
如果没有,则会把对象的引用地址复制一份
,放入字符串常量池,并返回字符串常量池中的引用地址
练习:
String s = new String("a") + new String("b");//new String("ab")
没有“ab”,而是一个引用指向new String(“ab”)
//在上一行代码执行完以后,字符串常量池中并没有"ab"
/*
1、jdk6中:在字符串常量池(此时在永久代)中创建一个字符串"ab"
2、jdk8中:字符串常量池(此时在堆中)中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回
*/
String s2 = s.intern();
System.out.println(s2 == "ab");//jdk6:true jdk8:true
System.out.println(s == "ab");//jdk6:false jdk8:true
调用intern方法时,如果池中已经包含了由equals(object)方法确定的与该字符串内容相等的字符串
,则返回池中的字符串地址。否则,该字符串对象将对象添加到池中,并返回对该字符串对象的地址
String x = "ab"; //常量池中已经拥有,不会放入
String s = new String("a") + new String("b");//new String("ab")
String s2 = s.intern();
System.out.println(s2 == "ab");//jdk6:true jdk8:true
System.out.println(s == "ab");//jdk6:false jdk8:false
使用intern()测试执行效率:空间使用上
结论:对于程序中大量存在存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。
直接 new String :由于每个 String 对象都是 new 出来的,所以程序需要维护大量存放在堆空间中的 String 实例,程序内存占用也会变高
使用 intern() 方法:由于数组中字符串的引用都指向字符串常量池中的字符串,所以程序需要维护的 String 对象更少,内存占用也更低
大的网站平台,内存需要存储大量字符串信息。如社交网站等待,这时候如果用字符串都调用intern()方法,就会明显降低内存的大小。
堆上存放重复的String对象必然是一种内存的浪费
命令行选项
UseStringDeduplication(bool) :开启String去重,默认是不开启的,需要手动开启。
PrintStringDeduplicationStatistics(bool) :打印详细的去重统计信息
stringDeduplicationAgeThreshold(uintx): 达到这个年龄的String对象被认为是去重的候选对象
String 去重的的实现
涉及到HashTable
当垃圾收集器工作的时候,会访问堆上存活的对象。对每个访问对象都会检查是否是候选的要去重的String对象
如果是要去重的对象,把对象的引用插入到队列中等待后续的处理
。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象
使用HashTable来记录所有被String对象使用的不重复的char数组。当去重的时候,会检查这个HashTable,来看堆上是否存在一个一模一样的char数组
如果存在,String对象会被调整引用那个数组,释放对原数组的引用,终会被垃圾收集器回收掉
如果查找失败,char数组就会被插入到hashTable,这样以后的时候就可以共享这个数组了。
Java 和 C++语言的区别,就在于垃圾收集技术和内存动态分配上,C++语言没有垃圾收集技术,需要程序员手动的收集。
垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
关于垃圾收集有三个经典问题:
哪些内存需要回收?
什么时候回收?
如何回收?
垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。
垃圾是指在运行程序中没有任何指针指向的对象(没有被引用的对象),这个对象就是需要被回收的垃圾
如果不及时对内存中的垃圾进行清理
,name这些垃圾对象所占的内存空间会一直保留到应用程序结束
,被保留的空间无法被其他对象使用。甚至可能导致内存溢出(内存泄露)
。
(不及时清理,空间被占用,甚至会OOM)
对于高级语言来说,一个基本认识是如果不进行垃圾回收,内存迟早会被消耗完
,因为不断地分配内存空间为背景下回收,就像不停地生产生活垃圾而从来不打扫一样。
除了释放没有的对象,垃圾回收也可以清楚内存里的记录碎片。碎片整理将所占用的堆内存移到一端,以便JVM将整理出的内存分配给新的对象
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证程序的正常运行
。而经常造成Stw的GC又跟不上实际的需求。所以才会不断地尝试对GC进行优化
在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代码:
MibBridge *pBridge= new cmBaseGroupBridge();
//如果注册失败,使用Delete释放该对象所占内存区域
if(pBridge->Register(kDestroy)!=NO ERROR)
delete pBridge;
这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担
。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏
,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃
。
(内存泄露概念:这个方法不用了,却没办法被GC回收,最终应用程序崩溃)
自动内存管理,无需手动参与内存的分配与回收,这样减低内存泄露和内存溢出的分险
没有垃圾回收器,java也会和c++一样,有各种指针,泄露问题
自动内存管理机制,将程序员从繁琐的内存管理中释放出来,可以更专业地专注于业务开发
垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。
其中,java堆是垃圾收集器的工作重点
从次数来说:
频繁收集yong区
较少收集old区
局部不懂perm区(或元空间)
垃圾标记阶段:对象存活判断
在堆里存放着几乎所有的java对象实例,在GC执行垃圾回收之前,需要区分出内存中哪些是存活对象,哪些是已死亡的对象
。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放其所占的内存空间。
判断对象存活的方式:引用计数算法
和可达性分析算法
对每个对象保存一个整形的引用计数器。用于记录对象被引用的情况
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收
优点:实现简单,垃圾对象便于辨别;判定效率高,回收没有延时性
。
缺点:需要单独的字段存储计数器,这样的做法增加了存储空间的开销
。
每次赋值都需要更新计数器,伴随着假发和减法操作,在增加了时间开销
。
引用计数器有一个严重的问题,即无法处理循环引用的情况。只是一个致命的缺陷,导致在java的垃圾回收器中没有使用这类算法
(容易导致内存泄露
(该回收的对象没被回收))
(面试时说内存泄露-------引用计数算法)
循环引用问题:
小结
引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
Python如何解决循环引用?
手动解除:很好理解,就是在合适的时机,解除引用关系。
使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。
(能解决循环引用造成的内存泄露)
相当于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄露的发生
相较于引用计数算法,这里的可达性分析就是java、c#
选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集
(Tracing Garbage Collection)。
所谓“GC Roots”根集合就是一组必须活跃的引用
基本思路:(被GC roots 直接或间接连接的为非垃圾对象)
可达性分析算法是以根对象集合(GC Roots)为起点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达
使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径被称为引用链
(Reference Chain)
如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以被标记为垃圾对象
在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才算存活对象
虚拟机栈中引用的对象
比如:各个线程被调用的方法中使用的局部变量和方法参数
本地方法栈内JNI(本地方法)
方法区中类静态属性引用的对象
比如:java类的引用类型静态变量(类变量,static修饰的)
方法区中常量引用的对象
比如:字符串常量池(String Table)里的引用
被同步锁synchronized持有的对象
Java虚拟机内部的引用
基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutofMemoryError),类加载器
除了这些固定的GC Roots集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC roots集合。比如分代收集和局部回收
如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
小技巧:
由于Root采用栈方式存储变量和指针,如果一个指针,他保存了堆内存里面的对象,但是又不是存放在堆内存当中,那它就是个GC Root
堆空间的周边,比如:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的,都可以作为GC Roots进行可达性分析
注意
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
这点也是导致GC进行时必须“Stop The World”的一个重要原因。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放
。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
对象的finalization机制
永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点
在finalize()时可能会导致对象复活。
Finalize() 方法的执行时间是没有保障的,他完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
一个糟糕的finalize()会严重影响GC的性能。
从功能上来说,finalize()方法与C++中的折构函数比较相似,但是java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的折构函数。
finalize()方法对应一个finalize线程,因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收
由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态
。
如果从所有的根节点都无法服务到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并不是“非死不可”的,这个时候他们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象的三种状态
可触及的
:从根节点开始,可以找到这个对象。
可复活的
:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
(finalize方法 对象自救,只能一次)
不可触及的
:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
以上3种状态,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收
判断一个对象ObjA是否可回收,至少要经历两次标记过程:
1.如果对象ObjA到GC Roots没有引用链,则进行第一次标记。
2.进行筛选,判断此对象是否有必要执行finalize()方法
①如果对象ObjA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过了,
则虚拟机视为“没有必要执行”,ObjA被判断为不可触及的。
②如果对象ObjA重写了finalize()方法,且还未执行过,那么ObjA会插入到F-Queue队列中,
由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行
③finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记时,
ObjA会被移除“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,
finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,
一个对象的finalize方法只会被调用一次。
执行过程 (从根节点开始遍历,标记出可达对象(不被回收的),清除非可达对象)
当堆中的有效空间(available memory)被耗尽的时候,就会停止程序(STW),然后进行两项工作,第一项则是标记,第二项则是清除
标记:collector(迭代器)从引用根节点开始遍历,标记所有被引用的对象
。一般在对象的Header中记录为可达对象。
清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
缺点:
效率不算高;(要遍历两次
)
进行GC时,会STW
这种方式清除出来的空闲内存不是连续的,产生内存碎片。需要维护一个空闲列表
注意:何为清除
这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里
。下次有新对象需要加载时,判断垃圾的位置空间是否足够,如果够,就存放
将活着的内存空间分成两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
(新生代里面就用到了复制算法,Eden区和from区存活对象整体复制到to区)
(句柄池也是如此)
优点:
没有标记和清除过程,简单实现,运行高效
复制过去后保证空间的连续性
,不会出现“碎片”问题(指针碰撞)
缺点:
此算法的缺点也是很明显的,就是需要两倍的内存空间(空间浪费
)
对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护refion直接对象引用关系,不管是内存占用或者时间开销也不小。
(改变存活对象的引用地址)
特别的:
如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。
(如果同年龄的对象大小超过空间的一半,移到old区里)
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的(eden 朝生夕死)。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本很高。时
标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片
,标记压缩算法由此而生。(指针碰撞)
执行过程:
第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间
标记-压缩算法与标记-清除算法的比较
标记—压缩算法的最终效果等同于标记-清楚算法执行完后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩算法
二者的本质差异在于标记-清除算法
是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并发的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址一次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
优点:
消除了标记-清除算法当中,内存区域分散的缺点,(使其内存空间连续,指针碰撞),当需要给新对象分配内存是,JVM只需要持有一个内存的起始地址即可。
消除了复制算法中,内存浪费的缺陷
缺点:
从效率上来说,标记-整理算法要地狱复制算法。
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
移动过程中,需要全程暂停用户应用程序。(STW)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。