JVM内存区域
-
虚拟机栈:线程私有,每次方法的调用都会创建一个栈帧,压入栈.栈帧中包括:局部变量表,操作数栈,出口信息,动态链接
-
程序计数器:线程私有,记录线程当前执行到的指令的地址.
-
本地方法栈:为本地方法的执行提供内存区域
-
堆:线程共享的数据区,几乎所有的对象实例都存放在堆中,少部分由于逃逸分析的优化,可能在栈中分配,JDK7时堆分为新生代,老年代,永久代.到JDK8方法区的实现变成了元空间
-
方法区:线程共享,存放了类的字节码信息以及运行时常量池.在JDK6的时候,静态变量和字符串常量池也在方法区内.到了JDK7,静态变量和字符串常量池存放在了堆中
垃圾收集机制
判断对象是否为垃圾
-
引用计数法,缺点,无法解决循环引用的现象
-
可达性分析法,GC Roots的引用链中的对象都存活
-
固定可作为gcroots的对象包括以下几种
-
各线程的虚拟机栈中各栈帧内局部变量表中引用的对象
-
类静态属性,和常量引用的对象
-
本地方法栈中引用的对象
-
java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如
NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
-
所有被同步锁(synchronized关键字)持有的对象
-
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
-
四种引用类型
-
强引用:代码中普遍存在的引用赋值,强引用关系在的对象永远不会被垃圾回收
-
软引用:描述一些有用,但不是必须的对象,在即将发生OutOfMemoryError之前,会对这些对象进行回收
-
弱引用:描述非必须的对象,但是发生GC时,收集器随时可以对这些对象进行回收
-
虚引用:对象是否被虚引用完全不会对其生存时间构成影响,唯一目的是对象被当做垃圾回收之后收到一个系统通知.比如,当创建一个ByteBuffer对象,会被一个Cleaner的虚引用对象所引用,当bytebuffer被回收时,Cleaner对象就会进入与之关联的引用队列,被一个ReferenceHandler的线程执行其中的clean方法,释放直接内存.
垃圾回收算法
-
标记清除算法(Mark-Sweep)
分标记和清除阶段,缺点是会造成内存空间的碎片化,在新生代一般不会使用该算法,因为新生代的对象大部分都是朝生夕灭的,极容易造成内部碎片
-
标记复制算法
解决的内存空间的碎片化问题.将新生代分成eden,from和to三个区域,将eden和from作为对象分配的空间,在垃圾收集时,将存活的对象放入to中,并将to变为from.若to中无法容纳幸存下来的对象,则由老年代做分配担保.
缺点是如果存活的对象过多会导致复制的成本过高,一般用于新生代,不适用于老年代
-
标记整理算法(Mark-Compact)
针对老年代的存亡特征(存活对象更多),在垃圾收集后,将存活的对象向内存的一端移动,以达到清除小碎片的目的,这样做优缺点并存
优点是减少了内存碎片,缺点是需要stw,开销大
典型垃圾收集器
-
serial/serialold
-
新生代使用标记复制算法,老年代采用标记整理算法
-
serial收集器是运行在客户端模式下的虚拟机很好的选择
-
serial虽然是串行工作的,但是在堆内存不是很大的时候停顿不会很久,这对用户程序来说是可接受的
-
-
ParNew/CMS
-
标记复制+标记清除 ,标记整理兜底
-
ParNew支持多线程并行收集,默认开启核心数与处理器核数相同
-
CMS实现了用户线程与垃圾回收线程并发的工作,减少了用户线程的停顿时间,即低延迟
-
CMS适用于服务端,因为关注停顿时间,所以保证了服务的响应时间.
-
若老年代内部碎片过多可能会出现并发失败的问题,这时候CMS会退化为SerialOld,使用标记整理算法清除老年代的内存碎片
-
CMS的缺点:由于占用了一部分处理器导致吞吐量下降,无法处理浮动垃圾,基于标记清除,会造成内部碎片最终有可能导致fullGC
-
-
G1
-
G1收集器运作过程大致分以下四个阶段
-
初始标记:标记一下GCRoots能直接关联到的对象,需要stw
-
并发标记:进行可达性分析,找出要回收的对象,不需要stw
-
最终标记:处理并发结束后的有变动的对象,需要stw
-
筛选回收:根据用户期望停顿时间筛选排好序的region作为回收集,将回收集中存活的对象放入空的region中,由多条线程并行完成.涉及存活对象的移动,必须stw
-
-
因此,G1除了并发标记阶段,其他阶段也要暂停用户线程,它并不纯粹追求低延迟,而是在延迟可控的情况下获得尽可能高的吞吐量
-
G1的优势:将堆空间划分为一个个的region,不追求一次将整个堆清理干净,而是在用户可接受的延迟范围内,清理收益更高的region区域
-
G1的缺点:G1的内存占用非常高,它需要为每个region都维护一张卡表
-
当一个对象大于region的一半时被称为巨型对象,G1不会对巨型对象进行拷贝,回收时被优先考虑
-
GC调优
个人理解:首先是尽量的增大一些堆的空间,并且使得新生代和老年代占比合理,这样就可以尽量减少gc的次数.然后根据应用程序的类型选择合适的垃圾收集器,如果是服务端注重响应时间的,应考虑g1或者cms这种低延迟的垃圾收集器,如果是科学计算类的,应选用parallelgc,提高吞吐量
Java类结构和字节码技术
类的字节码文件结构
概括:字节码文件的基本信息(魔数,版本,修饰符,父类,接口等),常量池信息,字段信息,方法信息
ClassFile { u4 magic; //Class 文件的标志 u2 minor_version;//Class 的小版本号 u2 major_version;//Class 的大版本号 u2 constant_pool_count;//常量池的数量 cp_info constant_pool[constant_pool_count-1];//常量池 u2 access_flags;//Class 的访问标记 u2 this_class;//当前类 u2 super_class;//父类 u2 interfaces_count;//接口数量 u2 interfaces[interfaces_count];//一个类可以实现多个接口 u2 fields_count;//字段数量 field_info fields[fields_count];//一个类可以有多个字段 u2 methods_count;//方法数量 method_info methods[methods_count];//一个类可以有个多个方法 u2 attributes_count;//此类的属性表中的属性数 attribute_info attributes[attributes_count];//属性表集合 }
常见字节码指令
iconst_1:把常量1压入操作数栈,-1~5使用iconst
bipush 10 :把10压入操作数栈,-127~128使用bipush
putstatic #2:把值赋给#2引用的静态变量
iload_i :把槽位i的int局部变量值压入操作数栈
aload_i:把i位置的对象引用压入操作数栈,0表示压入this
iinc x 1:把x自增1
astore_1:把操作数栈顶的引用赋值给局部变量表槽位为1的变量
多态原理
当执行到invokevirtual指令时,通过对象引用找到对象,然后在对象头找到该对象实际的Class,在字节码文件中有虚方法表即vtable,它在类加载的链接阶段就已经生成
根据虚方法表得到方法的具体地址,然后执行方法
异常处理
字节码中的方法内部有个异常表,异常表中有from和to标记要监测的指令行,若范围内发生异常,会和表中的type即异常的类型比较,若是type类型或子类,则跳转到target所在的行号
Finally原理
finally块中的指令会被复制多份,在try后,catch后都有finally块中的指令.
如果发生了catch捕捉不到的异常也是有可能的.因此异常表中会记录catch以外的异常,如果发生,则跳转到target所在指令执行finally块
synchronized原理
通过monitorenter 和monitorexit两条字节码指令实现,加锁时,先将对象引用压入操作数栈,然后执行monitorenter给对象头加锁,执行完代码,同理给对象头解锁
如果过程中出现异常,由于exception table中会有对同步代码块中代码的监测,因此即使发生异常也能够解锁
语法糖
默认构造
当没有对类显式的进行构造方法的声明,编译器会在编译时为类加上默认的空参构造
自动拆装箱
装箱:integer x = integer.valueof(1); 拆箱: int y = x.intValue();
jdk5引入自动拆装箱,上述代码在jdk5以后可以写成integer x = 1; int y = x;
Integer有缓存的机制,在-128~127内的integer对象是共用的,即多次调用valueof得到的是同一个对象
而在拆箱时,可能会引发空指针异常,需要特别注意
泛型擦除
jdk5引入,java代码在编译时会执行一个泛型擦除的操作,即泛型信息在编译成字节码后就丢失了,实际的类型会被当做object处理.
而在取值时,取出的也是object类型,只是编译器会额外做一个类型转换操作checkcast
需要注意的是,泛型的擦除实际上擦除的是方法体,即方法内code中的泛型信息.但是方法中有个叫做局部变量类型表LocalVariavleTypeTable中会记录局部变量的类型的泛型信息:
局部变量表:start length slot name signature name即变量名,signature即变量类型
局部变量表中会有 名为list 类型为java/util/List
而局部变量类型表中会记录list 的类型为java/util/List<java/lang/Integer>
可变参数
jdk5引入,在定义方法时,在最后一个形参中加...,就表示该形参可以接受多个参数值
可变参数只能作为函数最后一个参数,且在编译后的字节码文件中,可变参数实际上就是数组类型,因此数组与可变参数不能作为方法的重载
增强for
对于数组的增强for,在编译后实际就是普通for,因此,数组的增强for底层可以看成普通for
对于集合的增强for,在编译后实际上是迭代器的遍历,即集合增强for底层是迭代器
枚举类
jdk7引入,枚举是一种特殊的类,代码中定义了枚举,编译器在编译时会定义一个枚举类,该类中会为代码中的每个枚举都定义一个静态常量的枚举类实例,由于构造方法是私有的,因此枚举类无法被外部创建对象
twr
jdk7引入,在try的小括号中定义需要关闭资源的对象,这样就不需自己在finally块中对资源关闭,编译器在编译阶段会为其补上关闭资源的相关代码
匿名内部类
当代码中new了一个匿名内部类,编译器在编译时会创建一个额外的类 :外部类名$1.真正的代码则是new了一个该类的对象.
类加载过程
加载
将类的字节码载入方法区,内部采用cpp的instanceKlass描述java类,有以下重要的field:
_java_mirror , _super, _fields , _methods , _constants , _class_loader, _vtable , _itable
如果这个类的父类还没有加载,先加载父类
加载和链接可能是交替运行的
链接
-
验证:检查字节码文件的格式内容是否符合JVM规范,以及一些安全性检查
-
准备:为static变量分配空间,设置默认值
-
static变量jdk7以后存储在class对象中,即堆中
-
static变量分配空间和赋值(cinit)是两个步骤,分配空间在准备阶段,而赋值在初始化阶段
-
如果是static final基本类型,或者是字符串常量,则编译阶段值就已经确定,因此赋值在准备阶段完成
-
如果是static final类型,但属于引用类型,那么赋值也在初始化阶段
-
-
解析:将常量池的符号引用解析为直接引用.未解析则常量池中只有这个类的符号,并没有实际的地址,解析后常量池中就会记录该类的内存地址
初始化
初始化即调用<cinit>()V,虚拟机会保证类构造方法的线程安全
类的初始化是懒惰式的,即只有在需要初始化的时候才会执行cinit
class.forname默认会对类进行加载链接初始化
类加载器
种类
-
Boostrap ClassLoader 启动类加载器
-
Extension ClassLoader 扩展类加载器
-
Application ClassLoader 应用类加载器
-
自定义类加载器
双亲委派模式
概念
ClassLoader类中有loadClass方法,在将类加载进内存时,类加载器会判断,若类已经加载过则无需加载,
若没有加载过,会交由上级加载,若不在上级加载的范围内,则调用自己的findclass方法进行查找
优点
双亲委派模式保证了核心类库的安全,防止了用户有意或者无意的修改核心类,也防止了类的重复加载
如何打破双亲委派
因为双亲委派是通过loadclass方法实现的,因此可以自定义类加载器重写loadclass方法
JDBC中就打破了双亲委派机制,DriverManger是java的启动类,由bootstrap加载,但是在DriverManger会初始化驱动类,而驱动类并不在核心类库中,因此需要委托线程上下文加载器(默认是系统类加载器)加载
自定义类加载器
什么时候需要用到自定义类加载器
-
想要加载非classpath路径中的类文件
-
通过接口实现,希望解耦时,常用在框架设计中
-
希望将某些类隔离,不同应用的同名类都可以加载,而不会产生冲突,常见于tomcat容器
步骤
-
自定义加载器类继承父类ClassLoader
-
重写findclass方法,读取自定义路径下的字节码文件放入bytes数组,调用defineclass方法将字节码文件加载到内存中
运行期优化
JVM将字节码指令的执行状态分成了五个层次:0~4层,第0层是解释执行,1~4层由即时编译器编译执行
分层的原因
为了在启动速度和执行效率间达到平衡.程序刚开始执行不会进行编译,因为这会占用程序运行时间并且消耗内存,而在执行的过程中才会发现某些热点代码,这个时候进行即时编译,提高运行效率.
即时编译器JIT与解释器的区别
-
解释器将字节码解释为机器码,下次遇到相同的字节码仍会重复解释执行
-
JIT将字节码编译为机器指令,并存入Code Cache,下次遇到相同代码,直接执行已经编译好的指令
常见的运行期优化
-
逃逸分析:分析对象是否逃出了某些作用域的范围,比如逃出了方法,若没有逃逸,则可以做出一定优化,例如堆分配改为栈分配等,从而节省时间开销
-
方法内联:若方法不会太大,可以将方法代码粘贴到调用者的位置,从而节省时间开销
-
字段优化:成员变量在堆中,当需要频繁对成员变量操作时,可以复制一份引用在局部变量
-
反射优化:如果持续反射调用方法达到阈值(默认15),JVM就会在运行期间生成一个类,当通过反射调用invoke方法时,实际就调用了运行时生成类的方法,它将反射调用优化成了正常调用方法
JMM
原子性问题
对共享变量,例如静态变量的自增自减并不是原子操作,实际jvm的指令为:
-
getstatic i
-
iconst_1
-
iadd
-
putstatic i
通过对希望是原子操作的代码使用synchronized关键字可以保障代码的原子性,从字节码指令角度来说,是由monitorenter和monitorexit指令保证.
可见性问题
当线程对主存中某个变量不断进行操作时,可能会将主存中的变量复制到工作缓存中,从而导致主存变量的修改对线程不可见
volatile :修饰成员变量和静态变量,可以避免线程从自己工作缓存中查找变量的值,必须到主存中获取它的值
通过volatile关键字,保证了在多个线程之间,一个线程对volatile修饰的变量的修改对其它线程可见
synchronized既可以保证可见性,也可以保证原子性,但是它是重量级的操作
有序性问题
由于JIT的优化,某些时候会出现指令重排的情况,这样可能会造成意料之外的结果
通过使用volatile关键字修饰变量,使得其不受指令重排的影响
指令重排:在同一个线程内,JVM会在不影响正确性的前提下,调整语句的执行顺序.但在多线程环境下是可能会出现问题的
问题实际场景:设计单例类的时候,有一种叫双重检查锁的方式(double-checked locking):
这种方式在单线程下没问题,但是在多线程环境下是有问题的.
new创建一个对象的过程:new开辟空间并将引用压栈,dup复制引用,invokespecial初始化,putstatic将引用赋给变量.
但是由于指令重排序,putstatic可能在初始化之前,这样就会造成对象还未初始化,但是变量的引用已经不为null.其它线程就有可能拿到一个尚未初始化的对象.
public final class Singleton { //单例类的实现 private Singleton(){} //多线程环境下可能会出现问题(极低概率),因此应用volatile修饰 private static Singleton INSTANCE; public static Singleton getSingleton() { //如果有实例就直接返回,这样就防止了每次get时都加锁 if (INSTANCE == null){ synchronized (Singleton.class){ if (INSTANCE == null){ INSTANCE = new Singleton(); } } } return INSTANCE; } }
CAS(compare and swap)与原子类
概念
//共享变量 public static int a = 10; while(true){ //获取共享变量原来的值 int x = a; //修改后的结果 int result = x + 10; //如果a的值与获取时一样,即并未改变,则修改a的值为result,并退出循环,如果a发生变化,继续循环 if(cas(x,result)){ break; } }
CAS体现了一种乐观锁的思想,即无锁并发
优点:线程不会阻塞,没有线程切换的开销,效率较高
缺点:如果竞争过于激烈,会频繁的发生重试,效率反而降低
CAS底层实现
cas的底层依赖于Unsafe类来直接调用操作系统提供的CAS指令.
原子操作类
juc中提供了原子操作类,可以提供线程安全的操作,例如AutomicInteger,AutomicBoolean等,它们底层就是采用CAS+volatile实现的
synchronized优化
synchronized是重量级锁,体现了悲观锁的思想,jdk6对它做了一些优化
每个对象的都有对象头,对象头中记录了class指针和Mark Word,Mark Word中平时存储了对象的hash码,分代年龄等.当加锁时,这些信息就根据情况被替换为标记位,线程锁记录指针重量级锁指针,线程ID等内容
每个线程的栈帧中都会包含一个锁记录结构,内部可以存储锁定对象的Mark Word
轻量级锁
当线程尝试去获取锁的时候是通过CAS获取,如果获取失败,发现锁已经被其他线程占据,会将锁升级.如果获取成功,这个时候可以避免锁的升级
自旋锁
如果已经是重量级锁,当线程尝试去获取锁,如果获取锁失败,不会立刻陷入阻塞,而是自旋重试,重试几次后如果还失败,才会陷入阻塞
偏向锁
当线程重复给某个对象加锁时,会发生锁重入,因此在第一次加锁时,将线程id加入MarkWord