JVM的位置
JVM是运行在操作系统之上的,与硬件没有直接的交互,但是可以调用底层的硬件,用JIN (Java本地接口调用底层硬件接口,了解下就好,已经过时了)
JVM的体系结构
其中,栈、本地方法栈以及程序计数器是不会有GC(垃圾回收)的!而我们的调优基本都是在方法区和堆,大部分都是堆。
类加载器
概念
负责加载class文件,class文件 在文件开头有特定的文件标识 ,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
解释:
- Car.class 是由 .java 文件 经过编译而得来的 .class文件,存在本地磁盘
- ClassLoader: 类加载器,作用就是加载并初始化 .class文件 ,得到真正的 Class 类,即模板 (此处不明白则带着疑问继续往下看,为什么叫模板)
- Car Class : 由 Car.class 字节码文件,通过ClassLoader 加载并初始化而得,那么此时 这个 Car 就是当前类的模板,这个Car Class 模板就存在 【方法区】
- car1,car2,car3 : 是由Car模板经过实例化而得,即 new出来的 --> Car car1 = new Car() , Car car2 = new Car() ,Car car3 = new Car() , 因此可知,由一个模板,可以得到多个实例对象,即模板一个,实例多个, 所以,拿car1举例,car1.getClass 可以得到其模板Car 类,Car.getClassLoader() 可得到其装载器 。
类加载器的分类
虚拟机自带的加载器
- 启动类加载器 也叫根加载器 (Bootstrap):,由C++编写 ,程序中自带的类, 存储在$JAVAHOME/jre/lib/rt.jar中,如object类等。
- 扩展类加载器 (ExtClassLoader) :Java 编写 ,在我们平时看到的类路径中,凡是以javax 开头的,都是拓展包,存储在$JAVAHOME/jre/lib/ext/*.jar 中 。在jdk1.9之后为PlatformClassLoader。
- 应用程序类加载器 (AppClassLoader):即平时程序中自定义的类 new出来的。
自定义加载器
- Java.lang.ClassLoader的子类,用户可以定制类的加载方式,即如果你的程序有特殊的需求,你也可以自定义你的类加载器的加载方式 ,进入ClassLoader的源码,其为抽象类,因此在你定制化开发的时候,需要你定义自己的加载器类来继承ClassLoader抽象类即可,即 MyClassLoader extends ClassLoader。
上面例子的代码:
public class Car {
public static void main(String[] args) {
Car car1 = new Car();
Car car2 = new Car();
Car car3 = new Car();
System.out.println(car1.hashCode());
System.out.println(car2.hashCode());
System.out.println(car3.hashCode());
Class<? extends Car> aClass1 = car1.getClass();
ClassLoader classLoader = aClass1.getClassLoader();
System.out.println(classLoader);
System.out.println(classLoader.getParent());
System.out.println(classLoader.getParent().getParent());
}
}
输出结果:
双亲委派机制
先举一个例子,来说明下啥叫双亲委派,比如 有一个类叫 A.java ,当要使用A类时,类加载器要先去 启动类加载器(Bootstrap)中去找,如果找到就使用启动类加载器中的A类,不继续往下执行,但是如果找不到,则依次下放,去 拓展类加载器 中找,同理找到就用,找不到就继续下放,再去应用程序类加载器中找,找到就用,此时找不到就会报classNotFund Exception的异常。
概念:
- 当一个类收到类加载请求,它首先不会尝试自己去加载这个类,而是先把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的类加载请求都是应该传到启动类加载器中的,只有当其父类加载器自己无法完成这个请求的时候(在他的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
- 采用双亲委派的一个好处就是比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是会委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同一个Object对象。
java历史-沙箱安全机制(了解)
Java安全模型的核心就是java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境,沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
所有的Java程序运行都可以指定沙箱,可以定制安全策略。
组成沙箱的基本组件
- 字节码校验器(bytecode verifier)︰确保java类文件遵循java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。
- 类装载器(class loader):它防止恶意代码去干涉善意的代码;它守护了被信任的类库边界;它将代码归入保护域,确定了代码可以进行哪些操作。类装载器采用的机制就是双亲委派模式!
Native(重点)
native :在Java中是一个关键字,有声明,无实现。
以线程为例,不要以为线程是属于Java的一个东西,其实它是属于操作系统底层的,Java中通过Thread类的start() 类启动一个线程。
- 进入Thread的start()的源码,你会看到虽然调用的是start(),但其实调用的start0()这个方法, 最终是由 private native void start0(); 这段代码去跟底层做了交互实现,有声明,无实现,Java到此交由系统去处理了。
native:凡是带了native关键字的,说明java的作用范围达不到了,回去调用底层C语言的库!会进入本地方法栈,会调用本地方法接口(JNI)!JIN的作用就是扩展java的使用,融合不同的编程语言为java所用! native在内存区域中专门开辟了一块标记区域:Native Method Stack,登记native方法,在最终执行的时候,通过JNI加载本地方法库中的方法。
这里引申个题外知识哈,即我new 一个线程,当我执行 thread.start() ;这个方法之后,是不是会立即执行这个线程呢?
- 答案就是不一定,因为当你创建一个线程,调用start()方法后,是将线程从初始化状态变为就绪状态,而真正的执行,是要等cpu来进行调度,你才能执行,否则你就跟那就绪着,千万别信誓旦旦的说,肯定会立即执行,还是建议看一下 操作系统的课本。 本地接口的作用是融合不同的编程语言为Java所用,它的初衷是想融合C/C++ 程序,Java诞生之初,正式C/C++ 盛行之时,因此,Java要想立足,则必须要调用C/C++程序(打个比方:微某信和抖某音,起初阿抖想分享视频给阿音某好友,直接点分享就过去了,但是后来被阿音给禁止了,当初Java也是一样,你要想使用我的某些东西,那你就得给我整点“保护费”啊),于是在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法就是 Native Method Stack 中登记 native 方法,在 Exection Engine 执行时加载 native libraies。
PC寄存器(程序计数器)
-
记录了方法之间的调用和执行情况,类似班级的值日表,用来存储指向下一条指令的地址,也即将要执行的指令代码
-
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码。建议看一下【计算机操作系统】这本书,其实不光有pc,还有时间片的轮转,这里不多做介绍),有执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。
-
这块存储区域很小,他是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。
-
如果执行的是一个Native方法,那这个计数器就是空的,因为native已经不属于Java的范畴了 。
-
用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory = OOM)错误。
方法区
- 方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,
简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间, - 静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存
中,和方法区无关。
深入理解栈
记住 : 栈管运行,堆管存储。
栈:先进后出!
栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,他的生命周期是跟随线程的生命周期,线程结束那么栈内存也就随之释放, 对于栈来说不存在垃圾回收问题 ,只要线程已结束该栈就over了,是线程私有的。8种基本类型的变量 + 对象的引用变量 + 实例方法都是在函数的栈内存中分配。
栈的运行原理
栈中的数据都是以栈帧 (Stack Frame) 的格式存在,栈帧是一个内存区块,是一个有关方法和运行期数据的数据集;
当一个方法A被调用时就产生了栈帧 F1,并被压入到栈中,
A方法又调用 B方法,于是产生栈帧F2 ,也被压入栈,
B方法又调用 C方法, 于是产生栈帧F3,也被压入栈
……
执行完毕后,先弹出F3栈帧,再弹出 F2栈帧,再弹出 F1栈帧 ……
遵循 “先进后出” / “后进先出” 原则。
每个方法执行的同时都会创建一个栈帧,用于存储局部变量表,操作数据栈,动态连接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的操作过程 。
栈的大小和具体jvm的实现有关,通常在 256K ~ 756K 之间,约等于 1Mb左右。
HotSpot和堆
HotSpot是JVM的一种,后面讲的部门都是基于这个JVM的。
堆
jbk1.8之前java堆内存空间的划分
- JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)。
- 年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。
- 堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。
- 非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。
在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。元空间有注意有两个参数:
- MetaspaceSize :初始化元空间大小,控制发生GC阈值
- MaxMetaspaceSize : 限制元空间大小上限,防止异常占用过多物理内存。
为什么移除永久代?
移除永久代原因:为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。
有了元空间就不再会出现永久代OOM问题了!
分代概念
新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,存活下来的对象移动到 Survivor0区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Survivor1区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC仍然存活的对象移动到老年代。老年代存储长期存活的对象,占满时会触发 Major GC = Full GC,GC期间会停止所有线程等待 GC 完成,所以对相应要求高的应用尽量减少发生Major GC,避免响应超时。
- Minor GC:清理年轻代
- Major GC:清理老年代
- Full GC:清理整个堆空间,包括年轻代和永久代
所有GC都会停止所有应用进程。
为什么要设置两个Survivor区?
设置两个Survivor区最大的好处就是解决了碎片化;
假设现在只有一个survivor区,我们来模拟一下流程:
刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存。。。画面太美不敢看。。。这就好比我们上学时背包里所有东西紧挨着放,最后就可能省出一块完整的空间放饭盒。如果每件东西之间隔一点空隙乱放,很可能最后就要手提一路了。
JVM堆内存常用参数
使用JPofiler工具分析OOM原因
GC:垃圾回收
GC的作用区域:堆和方法区(方法区也是堆的一部分!)
引用计数法(reference-counting)(了解)
思想::每个对象有一个引用计数器,当对象被引用一次则计数器加1,当对象引用失效一次则计数器减1,对于计数器为O的对象意味着是垃圾对象,可以被GC回收。
但是有个缺点:比如对象A中有一个字段指向了对象B,而对象B中也有一个字段指向了对象A,而事实上他们俩都不再使用,但计数器的值永远都不可能为0,也就不会被回收,然后就发生了内存泄露。
复制算法(主要用于年轻代)
谁空谁是to区
复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到老年代中。
标记清除算法(老年代回收)
标记-清扫式垃圾回收器是一种直接的全面停顿算法。简单的说,它们找出所有不可达的对象,并将它们放入空闲列表Free。
清扫过程将分为标记阶段和清扫阶段。
图示:
缺点:需要两次扫描整个堆区,时间开销较大,同时可能出现内存碎片!
有点:不需要额外的空间。
标记压缩算法(老年代回收)
标记压缩算法是在标记清除算法的基础上改进的,需要再次扫描!
总结:
内存效率:复制算法>标记清除算法>标记压缩算法(时间复杂度)
内存整齐度:复制算法=标记压缩算法>标记清除算法
内存利用率:标记压缩算法=标记清除算法>复制算法