类加载器
1.类加载过程
步骤一:加载
加载:将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在内存上创建一个java.lang.Class对象用来封装类在方法区内的数据结构作为这个类的各种数据的访问入口。类的加载过程是由类加载器来完成,类加载器由JVM提供,也可以通过继承ClassLoader来实现自己的类加载器。
步骤二:链接
验证:主要是为了确保class文件中的字节流包含的信息是否符合当前JVM的要求,且不会危害JVM自身安全,比如校验文件格式、是否是cafe baby魔术、字节码验证等等。
准备:为类变量分配内存并设置类变量(是被static修饰的变量,变量不是常量,所以不是final的,就是static的)初始值的阶段。这些变量所使用的内存在方法区中进行分配。比如private static int age = 26;类变量age会在准备阶段过后为其分配四个(int四个字节)字节的空间,并且设置初始值为0,而不是26。若是final的,则在编译期就会设置上最终值。
解析:JVM会在此阶段把类的二进制数据中的符号引用替换为直接引用。对于一个方法的调用,编译器会生成一个包含目标方法所在的类、目标方法名、接收参数类型以及返回值类型的符号引用,来指代要调用的方法。解析阶段的目的,就是将这些符号引用解析为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必会触发解析与初始化)。
步骤三:初始化
初始化:初始化阶段是执行类构造器<clinit>()方法的过程,到了初始化阶段,才真正开始执行类定义的Java程序代码(或者说字节码)。比如准备阶段的那个age初始值是0,到这一步就设置为26。虚拟机会收集类及父类中的类变量及类方法组合为<clinit>方法,根据定义的顺序进行初始化。虚拟机会保证子类的<clinit>执行之前,父类的<clinit>方法先执行完毕。虚拟机会保证一个类的<clinit>方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>方法完毕。
步骤四:使用:对象都出来了,业务系统直接调用阶段。
步骤五:卸载:用完了,可以被GC回收了。
2.类加载器种类以及加载范围
启动类加载器(Bootstrap ClassLoader):最顶层类加载器,他的父类加载器是个null,也就是没有父类加载器。负责加载jvm的核心类库,比如java.lang.*等,从系统属性中的sun.boot.class.path所指定的目录中加载类库。它的具体实现由Java虚拟机底层C++代码实现。根类加载器负责加载%JAVA_HOME%/jre/lib下的jar包,以及由虚拟机参数-Xbootclasspath指定的类。
扩展类加载器(Extension ClassLoader):父类加载器是Bootstrap ClassLoader。从java.ext.dirs系统属性所指定的目录中加载类库,或者从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库,如果把用户的jar文件放在这个目录下,也会自动由扩展类加载器加载。继承自java.lang.ClassLoader。
应用程序类加载器(Application ClassLoader):父类加载器是Extension ClassLoader。负责加载来自java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH环境变量所指定的JAR包和类路径。继承自java.lang.ClassLoader。也叫System ClassLoader为系统(应用)类加载器。程序可以通过ClassLoader.getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器默认都以系统类加载器作为父加载器。
自定义类加载器(User ClassLoader):除了上面三个自带的以外,用户还能制定自己的类加载器,但是所有自定义的类加载器都应该继承自java.lang.ClassLoader。比如热部署、tomcat都会用到自定义类加载器。
3.双亲委派
如果一个类加载器收到了类加载的请求,它首先会从自己缓存里查找是否之前加载过这个class,加载过直接返回,没加载过的话它不会自己亲自去加载,它会把这个请求委派给父类加载器去完成,每一层都是如此,类似递归,一直递归到顶层父类。 也就是Bootstrap ClassLoader,只要加载完成就会返回结果,如果顶层父类加载器无法加载此class,则会返回去交给子类加载器去尝试加载,若最底层的子类加载器也没找到,则会抛出ClassNotFoundException。源码在java.lang.ClassLoader#loadClass(java.lang.String, boolean)。
为啥要有双亲委派? 防止内存中出现多份同样的字节码,安全。比如自己重写个java.lang.Object并放到Classpath中,没有双亲委派的话直接自己执行了,那不安全。双亲委派可以保证这个类只能被顶层Bootstrap Classloader类加载器加载,从而确保只有JVM中有且仅有一份正常的java核心类。如果有多个的话,那么就乱套了。比如相同的类instance of可能返回false,因为可能父类不是同一个类加载器加载的Object。
为什么需要破坏双亲委派模型? Java spi方式,比如jdbc4.0开始就是其中之一。热部署的场景会破坏,否则实现不了热部署。
(1)Jdbc: 以前的用法是未破坏双亲委派模型的,比如Class.forName(“com.mysql.cj.jdbc.Driver”);而在JDBC4.0以后,开始支持使用spi的方式来注册这个Driver,具体做法就是在mysql的jar包中的META-INF/services/java.sql.Driver文件中指明当前使用的Driver是哪个,然后使用的时候就不需要我们手动的去加载驱动了,我们只需要直接获取连接就可以了。Connection con = DriverManager.getConnection(url, username, password ); 首先理解一下为什么JDBC需要破坏双亲委派模式,原因是原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。例如,MySQL的mysql-connector-.jar中的Driver类具体实现的。原生的JDBC中的类是放在rt.jar包的,是由Bootstrap加载器进行类加载的,在JDBC中的Driver类中需要动态去加载不同数据库类型的Driver类,而mysql-connector-.jar中的Driver类是用户自己写的代码,那Bootstrap类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由Application类加载器去进行类加载。 这个时候就引入线程上下文件类加载器(Thread Context ClassLoader),通过这个东西程序就可以把原本需要由Bootstrap类加载器进行加载的类由Application类加载器去进行加载了。
(2)Tomcat: 因为一个Tomcat可以部署N个web应用,但是每个web应用都有自己的classloader,互不干扰。比如web1里面有com.test.A.class,web2里面也有com.test.A.class,如果没打破双亲委派模型的话,那么web1加载完后,web2在加载的话会冲突。因为只有一套classloader,却出现了两个重复的类路径,所以tomcat打破了,它是线程级别的,不同web应用是不同的classloader。
如何破坏双亲委派模型? 重写loadClass方法,别重写findClass方法,因为loadClass是核心入口,将其重写成自定义逻辑即可破坏双亲委派模型。
4.如何自定义一个类加载器
只需要继承java.lang.Classloader类,然后覆盖他的findClass(String name)方法即可,该方法根据参数指定的类名称,返回对应的Class对象的引用。 如果要实现自定义类,可以重写这两个方法来实现。但推荐重写findClass方法,而不是重写loadClass方法,因为loadClass方法内部会调用findClass方法。
5.热部署原理
采取破坏双亲委派模型的手段来实现热部署,默认的loadClass()方法先找缓存,你改了class字节码也不会热加载,所以自定义ClassLoader,去掉找缓存那部分,直接就去加载,也就是每次都重新加载。
6.Java类是如何被加载的
(1)Java类何时会被加载?最通俗易懂的答案就是:当运行过程中需要这个类的时候。
遇到new、getstatic、putstatic等指令时。即当遇到用以新建目标类实例的new指令时,初始化new指令的目标类;当遇到调用静态方法或者使用静态变量,初始化静态变量或方法所在的类;
对类进行反射调用的时候。
初始化某个类的子类的时候。
虚拟机启动时会先加载设置的程序主类。
使用JDK 1.7的动态语言支持的时候。
(2)怎么加载类
利用ClassLoader加载类很简单,直接调用ClassLoder的loadClass()方法即可。让ClassLoader去加载 “com.test.Dog” 这个类。
public class Test {
public static void main(String[] args) throws ClassNotFoundException {
Test.class.getClassLoader().loadClass("com.test.Dog");
}
}
(3)JVM是怎么加载类的
JVM默认用于加载用户程序的ClassLoader为AppClassLoader,不过无论是什么ClassLoader,它的根父类都是java.lang.ClassLoader。 在上面那个例子中,loadClass()方法最终会调用到ClassLoader.definClass1()中,这是一个Native方法。definClass1()对应的JNI方法为Java_java_lang_ClassLoader_defineClass1()。
static native Class<?> defineClass1(ClassLoader loader, String name, byte[] b, int off, int len, ProtectionDomain pd, String source);
(4)ClassLoader只会对类进行加载,不会进行初始化
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
//下面语句仅仅是加载Tester类
classLoader.loadClass("loader.Tester");
System.out.println("系统加载Tester类");
//下面语句才会初始化Tester类
Class.forName("loader.Tester");
}
JVM内存管理
1.JVM整体组成
类加载器(ClassLoader),运行时数据区(Runtime Data Area),执行引擎(Execution Engine),本地库接口(Native Interface)。 程序在执行之前先要把java代码转换成字节码(class文件),jvm首先需要把字节码通过一定的方式,类加载器(ClassLoader)把文件加载到内存中(运行时数据区(Runtime Data Area)) ,而字节码文件是jvm的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器(执行引擎(Execution Engine))将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口(本地库接口(Native Interface))来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。
2.JVM内存结构
(1)程序计数器
当前线程所执行字节码的行号指示器。在虚拟机的概念模型里,字节码解析器的工作是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于jvm的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,也就是任何时刻,一个处理器(或者说一个内核)都只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置,每个线程都有独立的程序计数器。 如果线程正在执行Java中的方法,程序计数器记录的就是正在执行虚拟机字节码指令的地址,如果是Native方法,这个计数器就为空(undefined)。 线程私有,Java内存区域中唯一一块不会发生OOM或StackOverflow的区域。
(2)虚拟机栈
描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完成的过程,都对应着一个线帧在虚拟机栈中入栈到出栈的过程。 线程私有,它的生命周期和线程相同。异常规定:StackOverflowError、OutOfMemoryError。如果线程请求的栈深度大于虚拟机所允许的栈深度就会抛出StackOverflowError异常。虚拟机是可以动态扩展的,如果扩展时无法申请到足够的内存就会抛出OutOfMemoryError异常。
(3)本地方法栈
本地方法栈与虚拟机栈的作用是一样的,只不过虚拟机栈是服务Java方法的,而本地方法栈是为虚拟机调用Native方法服务的。 在Java虚拟机规范中对于本地方法栈没有特殊的要求,虚拟机可以自由的实现它,因此在Sun HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一了。特性和异常同虚拟机栈。 线程私有,会发生StackOverflow。
(4)堆
Java堆是Java虚拟机中内存最大的一块,是被所有线程共享的,在虚拟机启动时候创建,Java堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化的技术将会导致一些微妙的变化,所有的对象都分配在堆上渐渐变得不那么“绝对”了。 特性:内存共享。异常规定:OutOfMemoryError。如果在堆中没有内存完成实例分配,并且堆不可以再扩展时,将会抛出OutOfMemoryError。 Java虚拟机规范规定,Java堆可以处在物理上不连续的内存空间中,只要逻辑上连续即可,就像我们的磁盘空间一样。在实现上也可以是固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是可扩展的,通过-Xmx和-Xms控制。 从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
(5)方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。 Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。 线程共享。当方法无法满足内存分配需求时会抛出OutOfMemoryError异常。 JDK8之前,Hotspot中方法区的实现是永久代(Perm),JDK8开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。
(6)运行时常量
是方法区的一部分,存常量(比如static final修饰的,比如String一个字符串)和符号引用。常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用,这部分在类加载后进入方法区的运行时常量池中,如String类的intern()方法。 是被所有线程共享的,会发生OOM。
(7)直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。 在JDK 1.4中新加入了NIO,引入了一种基于通道(Channel与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。 显然,本机直接内存的分配不会受到Java堆大小的限制,但是既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
3.栈和堆的区别
数据结构中的栈和堆:栈(FILO),堆是一种完全二叉树或者近似完全二叉树。
系统中的栈和堆: (1)栈:由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。 堆:是一个可动态申请的内存空间(其记录空闲内存空间的链表由操作系统维护),在java中,所有使用new xxx()构造出来的对象都在堆中存储一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
(2)申请响应
栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
(3)申请限制
栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。 堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
(4)堆栈缓存方式
栈使用的是一级缓存, 它们通常都是被调用时处于存储空间中,调用完毕立即释放。 堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。
4.堆内存组成部分
JVM的内存被分为了三个主要部分:新生代,老年代和永久代。 如果是Java8则没有Permanent Generation,Java8将此区域换成了Metaspace。
(1)新生代
所有新产生的对象全部都在新生代中,Eden区保存最新的对象,有两个SurvivorSpace——S1和S0,三个区域的比例大致为 8:1:1。当新生代的Eden区满了,将触发一次GC,我们把新生代中的GC称为minor garbage collections。minor gc是一种Stop the world事件,指发生在新生代的垃圾收集动作,主要是由于Eden区域不够分配了。大多数Java对象都是朝生夕灭,所以Minor GC是非常频繁的,一般回收速度也比较快。 当eden区满了,触发minor gc,这时还有被引用的对象,就会被分配到S0区域,剩下没有被引用的对象就都会被清除。 再一次GC时,S0区的部分对象很可能会出现没有引用的,被引用的对象以及S0中的存活对象,会被一起移动到S1中。eden和S0中的未引用对象会被全部清除。 接下来就是无限循环上面的步骤了,当新生代中存活的对象超过了一定的“年龄”,会被分配至老年代的Tenured区中。这个年龄可以通过参数MaxTenuringThreshold设定,默认值为15。 新生代管理内存采用的算法为GC复制算法(CopyingGC),也叫标记-复制法,原理是把内存分为两个空间:一个From空间,一个To空间,对象一开始只在From空间分配,To空间是空闲的。GC时把存活的对象从From空间复制粘贴到To空间,之后把To空间变成新的From空间,原来的From空间变成To空间。
(2)老年代
老年代用来存储活时间较长的对象,老年代区域的GC是major garbage collection,老年代中的内存不够时,就会触发一次。这也是一个stop the world事件,但是看名字就知道,这个回收过程会相当慢,因为这包括了对新生代和老年代所有对象的回收,也叫FullGC。 老年代管理内存最早采用的算法为标记-清理算法,这个算法很好理解,结合GC Root的定义,我们会把所有不可达的对象全部标记进行清除。 这个算法的劣势很好理解:对,会在标记清除的过程中产生大量的内存碎片,Java在分配内存时通常是按连续内存分配,这样我们会浪费很多内存。 所以,现在的JVM GC在老年代都是使用标记-压缩清除方法,在清除后的内存进行整理和压缩,以保证内存连续,虽然这个算法的效率是三种算法里最低的。
(3)永久代
永久代位于方法区,主要存放元数据,例如Class、Method的元信息,与GC要回收的对象其实关系并不是很大,我们可以几乎忽略其对GC的影响。 除了JavaHotSpot这种较新的虚拟机技术,会回收无用的常量和的类,以免大量运用反射这类频繁自定义ClassLoader的操作时方法区溢出。
5.新生代中为什么除了Eden区,还要设置两个Survivor区?
(1)为什么要有Survivor区
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。 老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。 Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
(2)为什么要设置两个Survivor区
设置两个Survivor区最大的好处就是解决了碎片化。假设只有一个survivor区,Minor GC时,Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。 碎片化带来的风险是极大的,严重影响JAVA程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间。 建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1。这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生。
6.对象创建时堆内存分配算法
(1)指针碰撞
前提要求堆内存的绝对工整的。所有用过的内存放一边,没用过的放另一边,中间放一个分界点的指示器,当有对象新生时就已经知道大小了,指示器只需要像没用过的内存那边移动与对象等大小的内存区域即可。
(2)空闲列表
假设堆内存并不工整,那么空闲列表最合适。JVM维护一个列表 ,记录哪些内存块是可用的,当对象创建时从列表中找到一块足够大的空间划分给新生对象,并将这块内存标记为已用内存。
7.对象在内存中的存储布局
(1)对象头:
包含两部分,自身运行时数据和类型指针。 自身运行时数据包含:hashcode、gc分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等。 对象指针就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。
(2)实例数据:
用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)
(3)对齐填充:
JVM要求对象起始地址必须是8字节的整数倍(8字节对齐),所以不够8字节就由这部分来补充。
8.对象怎么定位
如下两种,具体用哪种有JVM来选择,hotspot虚拟机采取的直接指针方式来定位对象。
(1)直接指针
栈上的引用直接指向堆中的对象。好处就是速度快。没额外开销。
(2)句柄
Java堆中会单独划分出一块内存空间作为句柄池,这么一来栈上的引用存储的就是句柄地址,而不是真实对象地址,而句柄中包含了对象的实例数据等信息。好处就是即使对象在堆中的位置发生移动,栈上的引用也无需变化。因为中间有个句柄。
9.什么时候抛出StackOverflowError
方法运行的时候栈的深度超过了虚拟机容许的最大深度的时候,所以不推荐递归的原因之一也是因为这个,效率低,死归的话很容易就StackOverflowError了。
10.Java中会存在内存泄漏吗,请简单描述。
虽然Java会自动GC,但是使用不当的话还是存在内存泄漏的,比如ThreadLocal忘记remove的情况。
11.栈帧是什么?包含哪些东西
栈帧中存放的是局部变量、操作数栈、动态链接、方法出口等信息,栈帧中的局部变量表存放基本类型 + 对象引用 + returnAddress,局部变量所需的内存空间在编译期间就完成分配了,因为基本类型和对象引用等都能确定占用多少slot,在运行期间也是无法改变这个大小的。
12.简述一个方法的执行流程
方法的执行到结束其实就是栈帧的入栈到出栈的过程,方法的局部变量会存到栈帧中的局部变量表里,递归的话会一直压栈压栈,执行完后进行出栈,所以效率较低,因为一直在压栈,栈是有深度的。
13.一个对象包含多少个字节
会占用16个字节。比如Object obj = new Object();因为obj引用占用栈的4个字节,new出来的对象占用堆中的8个字节,4+8=12,但是对象要求都是8的倍数,所以对象的字节对齐(Padding)部分会补齐4个字节,也就是占用16个字节。 再比如,这个对象大小为:空对象8字节+int类型4字节+boolean类型1字节+对象的引用4字节=17字节,需要8的倍数,所以字节对齐需要补充7个字节,也就是这段程序占用24字节。
public class NewObj {
int count;
boolean flag;
Object obj;
}
NewObj obj = new NewObj();
14.为什么把堆栈分成两个
栈代表了处理逻辑,堆代表了存储数据,分开后逻辑更清晰,面向对象模块化思想。 栈是线程私有,堆是线程共享区,这样分开也节省了空间,比如多个栈中的地址指向同一块堆内存中的对象。 栈是运行时的需要,比如方法执行到结束,栈只能向上增长,因此会限制住栈存储内容的能力,而堆中的对象是可以根据需要动态增长的。
15.栈的起始点是哪
main函数,也是程序的起始点。
16.为什么基本类型不放在堆里
因为基本类型占用的空间一般都是1-8个字节(所需空间很少),而且因为是基本类型,所以不会出现动态增长的情况(长度是固定的),所以存到栈上是比较合适的。反而存到可动态增长的堆上意义不大。
17.Java参数传递是值传递还是引用传递
值传递。基本类型作为参数被传递时肯定是值传递;引用类型作为参数被传递时也是值传递,只不过“值”为对应的引用。假设方法参数是个对象引用,当进入被调用方法的时候,被传递的这个引用的值会被程序解释到堆中的对象,这个时候才对应到真正的对象,若此时进行修改,修改的是引用对应的对象,而不是引用本身,也就是说修改的是堆中的数据,而不是栈中的引用。
18.为什么不推荐递归
因为递归一直在入栈入栈,短时间无法出栈,导致栈的压力会很大,栈也有深度的,容易爆掉,所以效率低下。
19.为什么参数大于2个要放到对象里
因为除了double和long类型占用局部变量表2个slot外,其他类型都占用1个slot大小,如果参数太多的话会导致这个栈帧变大,因为slot大。 放个对象的引用上去的话只会占用1个slot(一个slot4字节),增加堆的压力减少栈的压力,堆自带GC,所以这点压力可以忽略。
20.JVM的内存溢出
内存溢出:内存空间不足导致,新对象无法分配到足够的内存。 内存泄漏:应该释放的对象没有被释放,多见于自己使用容器保存元素的情况下。
(1)堆内存溢出,java.lang.OutOfMemoryError: Java heap space 解决方案:JDK自带的jvisualvm.exe工具可以分析.hprof和.dump文件。首先需要找出最大的对象,判断最大对象的存在是否合理,如何合理就需要调整JVM内存大小。如果不合理,那么这个对象的存在,就是最有可能是引起内存溢出的根源。通过GC Roots的引用链信息,就可以比较准确地定位出泄露代码的位置。
(2)超出GC开销限制,当出现java.lang.OutOfMemoryError: GC overhead limit exceeded异常信息时,表示超出了GC开销限制。当超过98%的时间用来做GC,但是却回收了不到2%的堆内存时会抛出此异常。 解决方案:通过-XX:-UseGCOverheadLimit参数来禁用这个检查,但是并不能从根本上来解决内存溢出的问题,最后还是会报出java.lang.OutOfMemoryError: Java heap space异常;调整JVM内存大小(-Xmx与-Xms)。
(3)虚拟机栈和本地方法栈溢出,如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。 StackOverflowError:主要原因有两点:单个线程请求的栈深度大于虚拟机所允许的最大深度;创建的线程过多。
a)单个线程请求的栈深度大于虚拟机所允许的最大深度 主要表现有以下几点:存在递归调用,存在循环依赖调用方法调用链路很深,比如使用装饰器模式的时候,对已经装饰后的对象再进行装饰。 影响递归的深度因素有:单个线程的栈空间大小(-Xss),局部变量表的大小。单个线程请求的栈深度超过内存限制导致的栈内存溢出,一般是由于非正确的编码导致的。
b)创建的线程过多 不断地建立线程也可能导致栈内存溢出,因为我们机器的总内存是有限制的,所以虚拟机栈和本地方法栈对应的内存也是有最大限制的。如果单个线程的栈空间越大,那么整个应用允许创建的线程数就越少。异常信息java.lang.OutOfMemoryError: unable to create new native thread。 虚拟机栈和本地方法栈内存 ≈ 操作系统内存限制 - 最大堆容量(Xmx) - 最大方法区容量(MaxPermSize) Java的线程是映射到操作系统的内核线程上,因此过多地创建线程有较大的风险,可能会导致操作系统假死。
(4)元数据区域的内存溢出,报错java.lang.OutOfMemoryError: Metaspace 元数据区域或方法区是用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。我们可以通过在运行时产生大量的类去填满方法区,直到溢出,如:代理的使用(CGlib)、大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
(5)运行时常量池的内存溢出,包括java.lang.OutOfMemoryError: PermGen space String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
(6)直接内存溢出,由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。 DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。
public class DirectMemoryOutOfMemoryErrorTest {
public static void main(String[] args) throws IllegalAccessException {
int _1M = 1024 * 1024;
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1M);
}
}
}
21.一个线程OOM后,其他线程是否可继续工作?
一个线程溢出后,进程里的其他线程还能照常运行。例子只演示了堆溢出的情况。如果是栈溢出,结论也是一样的。 其实发生OOM的线程一般情况下会死亡,也就是会被终结掉,该线程持有的对象占用的heap都会被gc了,释放内存。 因为发生OOM之前要进行gc,就算其他线程能够正常工作,也会因为频繁gc产生较大的影响。
22.JVM内存区域和内存模型
JVM内存区域是指JVM运行时将数据分区域存储,强调对内存空间的划分。 而内存模型(Java Memory Model,简称JMM)是定义了线程和主内存之间的抽象关系,即JMM定义了JVM在计算机内存(RAM)中的工作方式。
23.Java1.8为什么要使用元空间取代永久代的实现?
字符串存在永久代中,容易出现性能问题和内存溢出。类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
24.内存区域
线程共享
--堆:OutOfMemeryError:Java heap space
--元空间:OutOfMemeryError:Metaspace
线程私有
--虚拟机栈:StackOverFlowError
--本地方法栈:StackOverFlowError
--程序计数器
ThreadLocal
25.Java内存模型
Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。 Java内存模型(JMM)控制Java线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。 JMM规定了线程的工作内存和主内存的交互关系,以及线程之间的可见性和程序的执行顺序。
(1)计算机高速缓存和缓存一致性
计算机在高速的CPU和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。 在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。 当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。 为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。
(2)JVM主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。 Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。 这里的工作内存是JMM的一个抽象概念,也叫本地内存,其存储了该线程以读/写共享变量的副本。 就像每个处理器内核拥有私有的高速缓存,JMM中每个线程拥有私有的本地内存。 不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java线程间的通信采用的是共享内存方式。 这里所讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
(3)重排序和happens-before规则
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。 java编译器禁止处理器重排序是通过在生成指令序列的适当位置会插入内存屏障(重排序时不能把后面的指令重排序到内存屏障之前的位置)指令来实现的。
(4)happens-before
从JDK5开始,java 内存模型提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。 如果A happens-before B,那么Java内存模型将向程序员保证—— A操作的结果将对B可见,且A的执行顺序排在B之前。 重要的happens-before规则如下:
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
监视器锁规则:对一个监视器锁的解锁,happens-before于随后对这个监视器锁的加锁。(Synchronized规则)
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。(volatile规则)
线程的start()方法happen-before该线程所有的后续操作。(线程启动规则)
线程所有的操作happen-before其他线程在该线程上调用join返回成功后的操作。
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)volatile关键字
volatile可以说是JVM提供的最轻量级的同步机制,当一个变量定义为volatile之后,它将具备两种特性: 保证此变量对所有线程的可见性。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。 注意,volatile虽然保证了可见性,但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。 而synchronized关键字则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得线程安全的。 禁止指令重排序优化。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
N.参考
(1)讲一讲JVM的组成
(3)数据结构:堆
GC垃圾回收
1.判断对象是否能被回收的算法
(1)引用计数法
给对象添加一个引用计数器,每当有一个地方引用他的时候该计数器的值就+1,当引用失效的时候该计数器的值就-1;当计数器的值为0的时候,jvm判定此对象为垃圾对象。存在内存泄漏的bug,比如循环引用的时候,所以jvm虚拟机采取的是可达性分析法。
(2)可达性分析法
有一些根节点GC Roots作为对象起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的时候,则证明此对象为垃圾对象。
可以作为GC Root的对象可以主要分为四种:
虚拟机栈中的引用的对象。
方法区中的类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(native方法)引用的对象。
2.方法区会被回收吗
Java虚拟机规范中确实说过可以不要求虚拟机在方法区中实现垃圾回收,而且在方法区中进行垃圾回收的“性价比”一般比较低,方法区的垃圾收集主要回收两部分内容:废弃的常量和无用的类。 废弃的常量,以常量池中字面量的回收为例,假如一个字符串“abc”已经进入常量池中,但是当前系统已经没有任何一个String对象叫做“abc”的,也没有任何其他地方引用这个字面量,这个“abc”常量就会被清理出常量池。 判断一个无用的类需要同时满足下面3个条件才能算是“无用的类”:a)该类的所有实例都已经被回收,b)加载该类的ClassLoader已经被回收,c)该类对应的java.lang.Class对象已经没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.对象死亡过程
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中。并在稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行它。这里所谓“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果有一个对象在finalize()方法中执行缓慢,或者发生死循环,将可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。 finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象这个时候,未被重新引用,那它基本上就真的被回收了。 该对象没有重写finalize()方法或finalize()已经被执行过则直接回收(第一次标记)、否则将对象加入到F-Queue队列中(优先级很低的队列)在这里finalize()方法被执行,之后进行第二次标记,如果对象仍然应该被GC则GC,否则移除队列。(在finalize方法中,对象很可能和其他GC Roots中的某一个对象建立了关联,那就自救了,就不会被GC掉了,finalize方法只会被调用一次,且不推荐使用finalize方法)
4.GC是什么?为什么要GC
GC:垃圾收集,GC能帮助我们释放jvm内存,可以一定程度避免OOM问题,但是也无法完全避免。Java的GC是自动工作的,不像C++需要主动调用。 当new对象的时候,GC就开始监控这个对象的地址大小和使用情况了,通过可达性分析算法寻找不可达的对象然后进行标记看看是否需要GC回收掉释放内存。
5.你能保证GC执行吗?
不能,我只能通过手动执行System.gc()方法通知GC执行,但是是否执行的未知的。
6.对象的引用类型有哪几种,分别介绍下
Reference对象定义如下。
public abstract class Reference<T> {
//引用的对象
private T referent;
//回收队列,由使用者在Reference的构造函数中指定
volatile ReferenceQueue<? super T> queue;
//当该引用被加入到queue中的时候,该字段被设置为queue中的下一个元素,以形成链表结构
volatile Reference next;
//在GC时,JVM底层会维护一个叫DiscoveredList的链表,存放的是Reference对象,discovered字段指向的就是链表中的下一个元素,由JVM设置
transient private Reference<T> discovered;
//进行线程同步的锁对象
static private class Lock { }
private static Lock lock = new Lock();
//等待加入queue的Reference对象,在GC时由JVM设置,会有一个java层的线程(ReferenceHandler)源源不断的从pending中提取元素加入到queue
private static Reference<Object> pending = null;
}
一个Reference对象的生命周期:主要分为Native层和Java层两个部分。 Native层在GC时将需要被回收的Reference对象加入到DiscoveredList中(代码在referenceProcessor.cpp中process_discovered_references方法),然后将DiscoveredList的元素移动到PendingList中(代码在referenceProcessor.cpp中enqueue_discovered_ref_helper方法),PendingList的队首就是Reference类中的pending对象。 Java层流程比较简单:就是源源不断的从PendingList中提取出元素,然后将其加入到ReferenceQueue中去,开发者可以通过从ReferenceQueue中poll元素感知到对象被回收的事件。另外需要注意的是,对于Cleaner类型(继承自虚引用)的对象会有额外的处理:在其指向的对象被回收时,会调用clean方法,该方法主要是用来做对应的资源回收,在堆外内存DirectByteBuffer中就是用Cleaner进行堆外内存的回收,这也是虚引用在java中的典型应用。
(1)强引用:
发生GC的时候不会回收强引用所关联的对象。比如new就是强引用。
(2)软引用SoftReference:
有用但非必须的对象,在OOM之前会把这些对象列进回收范围之中进行第二次回收,若第二次回收还没有足够的内存,则会抛出OOM。也就是第一次快要发生OOM的时候不会立马抛出OOM,而是会回收掉这些软引用,然后再看内存是否足够,若还不够才会抛出OOM。 软引用会在内存不足时被回收,内存不足的定义和该引用对象get的时间以及当前堆可用内存大小都有关系。 软引用的实现很简单,就多了两个字段:clock和timestamp。clock是个静态变量,每次GC时都会将该字段设置成当前时间。timestamp字段则会在每次调用get方法时将其赋值为clock(如果不相等且对象没被回收)。 refs_lists中存放了本次GC发现的某种引用类型(虚引用、软引用、弱引用等),而process_discovered_reflist方法的作用就是将不需要被回收的对象从refs_lists移除掉,refs_lists最后剩下的元素全是需要被回收的元素,最后会将其第一个元素赋值给上文提到过的Reference.java#pending字段。 ReferencePolicy一共有4种实现:NeverClearPolicy,AlwaysClearPolicy,LRUCurrentHeapPolicy,LRUMaxHeapPolicy。 其中NeverClearPolicy永远返回false,代表永远不回收SoftReference,在JVM中该类没有被使用,AlwaysClearPolicy则永远返回true。SoftReference到底什么时候被回收,和使用的策略(默认应该是LRUCurrentHeapPolicy),堆可用大小,该SoftReference上一次调用get方法的时间都有关系。
(3)弱引用WeakReference:
有用但非必须的对象,比软引用更弱一些,只要开始GC,不管你内存够不够,都会将弱引用所关联的对象给回收掉。 WeakReference在Java层只是继承了Reference,没有做任何的改动。那referent字段是什么时候被置为null的呢?对象不可达后,引用字段就会被置为null,然后对象就会被回收。
(4)虚引用PhantomReference:
也叫幽灵引用/幻影引用,无法通过虚引用获得对象,它的意义在于能在这个对象被GC掉时收到一个系统通知,仅此而已。 可以看到虚引用的get方法永远返回null,虚引用能够在指向对象不可达时得到一个’通知’(其实所有继承References的类都有这个功能)。 严格的说,虚引用是会影响对象生命周期的,如果不做任何处理,只要虚引用不被回收,那其引用的对象永远不会被回收。所以一般来说,从ReferenceQueue中获得PhantomReference对象后,如果PhantomReference对象不会被回收的话(比如被其他GC ROOT可达的对象引用),需要调用clear方法解除PhantomReference和其引用对象的引用关系。 虚引用在Jdk中有哪些场景下用到了呢?DirectByteBuffer中是用虚引用的子类Cleaner.java来实现堆外内存回收的。
7.垃圾收集算法有哪些
标记清除:分为两步:标记和清除。首先需要标记出所有需要回收的对象,然后进行清除回收变为可用内存。缺点:效率低,会产生垃圾碎片 。 复制算法:将可用堆内存按照容量分为大小相等的两块,每次只用一块,当这块内存快用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存一次清理掉。年轻代from/to(s1/s2)采取的就是此种算法。老年代一般不会采取此种算法,因为老年代都是大对象且存活的久的,空间压缩一半代价略高。优点:效率较高、不会产生碎片。缺点:将内存缩小为原来的一半,代价略高。 标记整理:分为两步:标记和整理。整理其实也是两步:整理+清除。整理让所有存活的对象都移动到一端,然后清理掉边界以外的内存。优点:不会产生碎片问题,适合年老代的大对象存储,不像复制算法那样浪费空间。缺点:效率赶不上复制算法。 分代算法:并不是新算法,而是根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。
8.为什么要分代,分代垃圾回收是怎么工作的
因为在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间会相对较长,也有很多对象完全没必要遍历,比如大对象存活的时间更长,遍历下来发现不需要回收,这样更浪费时间。所以才有了分代,分治的思想,进行区域划分,把不同生命周期的对象放在不同的区域,不同的区域采取最适合他的垃圾回收方式进行回收。 分代回收基于这样一个理念:不同的对象的生命周期是不一样的,因此根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。这样来提高回收效率。 新生代执行流程:把 Eden + From Survivor(S1) 存活的对象放入 To Survivor(S2) 区;清空 Eden 和S1 区;S1 和 S2 区交换,S1 变 S2,S2变S1。每次在S1到S2移动时都存活的对象,年龄就+1,当年龄到达15(默认配置是15)时,升级为老年代。大对象也会直接进入老年代。 老年代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。
9.垃圾回收器有哪些
新生代回收器:Serial、ParNew、Parallel Scavenge;
老年代回收器:Serial Old、Parallel Old、CMS;
新生代和老年代回收器:G1;
(1)Serial:
Serial收集器是最基本、发展历史最悠久的收集器。JDK1.3.1前是HotSpot新生代收集的唯一选择。 特点:针对新生代;采用复制算法;单线程收集;进行垃圾收集时,必须暂停所有工作线程,直到完成。 优势:简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个cpu来说没有了上下文之间的的切换,效率比较高。 劣势:会在用户不知道的情况下停止所有工作线程。在GC进行时,程序会进入长时间的暂停时间,一般不太建议使用。 使用场景:Client模式(桌面应用),在用户的桌面应用场景中,可用内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,这是可以接受的。单核服务器,对于限定单个CPU的环境来说,Serial收集器没有线程切换开销,可以获得最高的单线程收集效率。 参数设置:
-XX:+UseSerialGC:添加该参数来显式的使用串行垃圾收集器。
(2)ParNew:
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余均和Serial收集器一致。 特点:采取复制算法,用于新生代,是Serial的多线程版本,多个GC线程同时工作,但是也会产生StopTheWorld,因为不能和工作线程并行。 优势:多线程版本的Serial,可以更加有效的利用系统资源。 劣势:同Serial,会在用户不知道的情况下停止所有工作线程 使用场景:Server模式下使用,亮点是除Serial外,目前只有它能与CMS收集器配合工作,是一个非常重要的垃圾回收器。 参数设置:
-XX:+UseConcMarkSweepGC:指定使用CMS后,会默认使用ParNew作为新生代收集器;
-XX:+UseParNewGC:强制指定使用ParNew;
-XX:ParallelGCThreads:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;
(3)Parallel Scavenge:
Parallel Scavenge也是一款用于新生代的多线程收集器,也是采用复制算法。与ParNew的不同之处在于Parallel Scavenge收集器的目的是达到一个可控制的吞吐量,而ParNew收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间。 特点:新生代收集器;采用复制算法;多线程收集;关注点与其他收集器不同:CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达一个可控制的吞吐量。是吞吐量优先的收集器,提供了很多参数来调节吞吐量。 优势:追求高吞吐量,高效利用CPU,是吞吐量优先,且能进行精确控制。 劣势:应该说是特点,追求高吞吐量必然要牺牲一些其他方面的优势,不能做到既,又。ParNew收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间,原本10s收集一次, 每次停顿100ms, 设置完参数之后可能变成5s收集一次, 每次停顿70ms. 停顿时间变短, 但收集次数变多。 使用场景:根据相关特性,我们很容易想到它的使用场景,即:当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,程序主要在后台进行计算,而不需要与用户进行太多交互等就特别适合Parallel Scavenge收集器。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序等。 参数设置:
-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,大于0的毫秒数;
-XX:GCTimeRatio:设置垃圾收集时间占总时间的比率,0<n<100的整数;
-XX:+UseParallelGC:指定使用Parallel Scavenge
(4)Serial Old:
Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。 特点:针对老年代;采用”标记-整理”算法(还有压缩,Mark-Sweep-Compact);单线程收集,在工作时会产生StopTheWorld;优劣势基本和Serial无异,它是和Serial收集器配合使用的老年代收集器。 使用场景:Client模式;单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用。
(5)CMS:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。采用的算法是“标记-清除”。 运作过程分为四个步骤:初始标记,标记GC Roots能够直接关联到达对象;并发标记,进行GC Roots Tracing的过程;重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录;并发清除,用标记清除算法清除对象。 特点:针对老年代;基于”标记-清除”算法(不进行压缩操作,产生内存碎片);以获取最短回收停顿时间为目标;并发收集、低停顿;需要更多的内存;主流垃圾收集器之一。当应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,那么可以选择CMS。 优势:停顿时间短;吞吐量大;并发收集。 劣势:对CPU资源非常敏感;无法收集浮动垃圾;容易产生大量内存碎片。 使用场景:与用户交互较多的场景;希望系统停顿时间最短,注重服务的响应速度;以给用户带来较好的体验;如常见WEB、B/S系统的服务器上的应用。 参数设置:
-XX:+UseConcMarkSweepGC:指定使用CMS收集器
(6)Parallel Old:
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法,可以充分利用多核CPU的计算能力。 特点:针对老年代;采用”标记-整理”算法;多线程收集;优劣势参考Parallel Scavenge收集器,吞吐量优先。 使用场景:JDK1.6及之后用来代替老年代的Serial Old收集器;特别是在Server模式,多CPU的情况下;这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge(新生代)加Parallel Old(老年代)收集器的”给力”应用组合。 参数设置:
-XX:+UseParallelOldGC:指定使用Parallel Old收集器
(7)G1:
G1(Garbage-First)是JDK7-u4才推出商用的收集器。 特点:并行与并发,G1能充分利用多CPU,多核环境下的硬件优势。分代收集,收集范围包括新生代和老年代,能够采用不同的方式去处理新创建的对象和已经存活了一段时间的对象,不需要与其他收集器进行合作。空间整合,G1从整体上来看基于“标记-整理”算法实现的收集器,从局部上看是基于复制算法实现的,因此G1运行期间不会产生空间碎片。可预测的停顿,G1能建立可预测的时间停顿模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,低停顿的同时实现高吞吐量。面向服务端应用,将来替换CMS。 优势:能充分利用多CPU、多核环境下的硬件优势;能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;不会产生内存碎片,有利于长时间运行;除了追求低停顿处,还能建立可预测的停顿时间模型;G1收集器是当今收集器技术发展的最前沿成果。 劣势:G1需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在G1中需要占用大量的内存,可能达到整个堆内存容量的20%甚至更多。而且G1中维护记忆集的成本较高,带来了更高的执行负载,影响效率。按照《深入理解Java虚拟机》作者的说法,CMS 在小内存应用上的表现要优于G1,而大内存应用上G1更有优势,大小内存的界限是6GB到8GB。所以,尽管是最前沿的成果,也不是完美无缺的。 使用场景:G1已经基本全面压制cms、parallel等回收器,缺点见上面的劣势。但如果不是追求极致的性能,基本可以无脑G1。 参数设置:
-XX:+UseG1GC:指定使用G1收集器;
-XX:InitiatingHeapOccupancyPercent:当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45;
-XX:MaxGCPauseMillis:为G1设置暂停时间目标,默认值为200毫秒;
-XX:G1HeapRegionSize:设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region;
10.详细介绍一下CMS垃圾回收器
采取标记清除算法,老年代并行收集器,号称以最短STW时间为目标的收集器,并发高、停顿低、STW时间短的优点。主流垃圾收集器之一。 主要分为四阶段: 初始标记:只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。所以此阶段会STW,但时间很短。 并发标记:进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。不会STW。 重新标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。STW时间会比第一阶段稍微长点,但是远比并发标记短,效率也很高。 并发清除:清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。
优点:
并发高、停顿低、STW时间短。
缺点:
对cpu资源非常敏感(并发阶段虽然不会影响用户线程,但是会一起占用CPU资源,竞争激烈的话会导致程序变慢)。 无法处理浮动垃圾,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,失败后而导致另一次Full GC的产生,由于CMS并发清除阶段用户线程还在运行,伴随程序的运行自然会有新的垃圾产生,这一部分垃圾是出现在标记过程之后的,CMS无法在本次去处理他们,所以只好留在下一次GC时候将其清理掉。 内存碎片问题(因为是标记清除算法)。当剩余内存不能满足程序运行要求时,系统将会出现Concurrent Mode Failure,临时CMS会采用Serial Old回收器进行垃圾清除,此时的性能将会被降低。
11.详细介绍一下G1垃圾回收器
采取标记整理算法,并行收集器。
特点:
并行与并发执行:利用多CPU的优势来缩短STW时间,在GC工作的时候,用户线程可以并行执行。 分代收集:无需其他收集器配合,G1自己会进行分代收集。 空间整合:不会像CMS那样产生内存碎片。 可预测的停顿:可以手动控制一个长度为M毫秒的时间片段(可以用JVM参数 -XX:MaxGCPauseMillis指定),设置完后垃圾收集的时长不得超过这个(近实时)。
原理:
G1将新生代、老年代的物理空间划分取消了,而是将堆划分为若干个区域(region),每个大小都为2的倍数且大小全部一致,最多有2000个。除此之外, G1专门划分了一个Humongous区,它用来专门存放超过一个region 50%大小的巨型对象。在正常的处理过程中,对象从一个区域复制到另外一个区域,同时也完成了堆的压缩。 G1并不是简单的把堆内存分为新生代和老年代两部分,而是把整个堆划分为多个大小相等的独立区域(Region),新生代和老年代也是一部分不需要连续Region的集合。G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。 Region不是孤立的,也就是说一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是整个堆中任意的对象都可以相互引用,那么在“可达性分析法”来判断对象是否存活的时候也无需扫描整个堆,Region之间的对象引用以及其他收集其中新生代和老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。 两种GC模式:YoungGC和MixedGC,两种都是StopTheWorld(STW)的。YoungGC主要是对Eden区进行GC,MixGC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。
步骤:
初始标记:仅仅标记GCRoots能直接关联到的对象,且修改TAMS的值让下一阶段用户程序并发运行时能正确可用的Region中创建的新对象。速度很快,会STW。 并发标记:进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。不会STW。 最终标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。STW时间会比第一阶段稍微长点,但是远比并发标记短,效率也很高。 筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。
12.Minor GC与Full GC
Minor GC: 新生代内存(Eden区)不够用时候发生,也叫YGC。
Full GC: 老年代被写满、持久代被写满、System.gc()被显示调用(只是会告诉需要GC,什么时候发生并不知道)。
13.新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?
新生代回收器:Serial、ParNew、Parallel Scavenge 老年代回收器:Serial Old、Parallel Old、CMS 整堆回收器:G1 新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。标记整理很适合大对象,不会产生空间碎片。
14.栈上分配是什么意思
JVM允许将线程私有的对象分配在栈上,而不是分配在堆上。分配在栈上的好处是栈上分配不需要考虑垃圾回收,因为出栈的时候对象就顺带着一起出去了,没了,而不需要垃圾回收器的介入,从而提高系统性能。
15.对象逃逸
逃逸的目的是判断对象的作用域是否有可能逃出函数体。对象实例user是类的成员变量,可以被任何线程访问,因此它属于逃逸对象。
private User user;
private void hello(){
user = new User();
}
但如果我们将代码稍微改动一下,该对象就可以线程非逃逸的了。可以看到user实例作用域只在hello函数中,不会被其他线程访问到,也不会访问。所以该user实例对象的作用域只在该函数中,因此它并未发生逃逸。对于这样的情况,虚拟机就有可能将其分配在栈上,而不在堆上。
private void hello(){
User user = new User();
}
16.TLAB
全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。 如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。 TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。 TLAB的本质其实是三个指针管理的区域:start,top 和 end,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中start和end是占位用的,标识出eden里被这个TLAB所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。 TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。从这一点看,它被翻译为“线程私有分配区”更为合理一点。 当一个TLAB用满(分配指针stop撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。
TLAB的缺点: 事务总不是完美的,TLAB也又自己的缺点。因为TLAB通常很小,所以放不下大对象。TLAB空间大小是固定的,但是这时候一个大对象,TLAB剩余的空间已经容不下它了。(比如100kb的TLAB,来了个110KB的对象)。TLAB空间还剩一点点没有用到,有点舍不得。(比如100kb的TLAB,装了80KB,又来了个30KB的对象) 所以JVM开发人员做了以下处理,设置了最大浪费空间。 当剩余的空间小于最大浪费空间,那该TLAB属于的线程在重新向Eden区申请一个TLAB空间。进行对象创建,还是空间不够,那你这个对象太大了,去Eden区直接创建。 当剩余的空间大于最大浪费空间,那这个大对象请你直接去Eden区创建,TLAB放不下没有使用完的空间。 TLAB允许浪费空间,导致Eden区空间不连续,积少成多。
17.简述下对象的分配规则
对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次YGC。并将还活着的对象放到from/to区,若本次YGC后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次YGC那么对象会进入Survivor区,之后每经过一次YGC那么对象的年龄加1,直到达到阀值对象进入老年区。默认阈值是15。可以通过-XX:MaxTenuringThreshold参数来设置。 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。无需等到-XX:MaxTenuringThreshold参数要求的年龄。 空间分配担保。每次进行YGC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC。如果小于,检查HandlePromotionFailure设置,如果true则只进行YGC,如果false则进行Full GC。
18.在评估GC收集器的优劣时一般考虑以下几点:
吞吐量
GC开销
暂停时间
GC频率
堆空间
对象生命周期
JVM执行过程
1.JVM栈帧
JVM执行字节码指令是基于栈的架构,就是说所有的操作数都必须先入栈,然后再根据需要出栈进行操作计算,再把结果进行入栈,这个流程和基于寄存器的架构是有本质区别的,而基于寄存器架构来实现,在不同的机器上可能会无法做到完全兼容,这也是Java会选择基于栈的设计的原因之一。 当我们调用一个方法时,参数是怎么传递的,返回值又是怎么保存的,一个方法调用之后又是如何继续下一个方法调用的呢?调用过程中肯定会存储一些方法的参数和返回值等信息,这些信息存储在哪里呢? 我们知道,每次调用一个方法就会产生一个栈帧,当一个方法调用完成时,它所对应的栈帧将被销毁,无论这种完成是正常的还是突然的(抛出一个未捕获的异常)。所以我们肯定可以想到栈帧就存储了所有调用过程中需要使用到的数据。 每个栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)和额外的附加信息。 在给定的线程当中,永远只有一个栈帧是活动的,所以活动的栈帧又称之为当前栈帧,而其对应的方法则称之为当前方法,定义了当前方法的类则称之为当前类。当一个方法调用结束时,其对应的栈帧也会被丢弃。
(1)局部变量表(Local Variables)
局部变量表是以数组的形式存储的,而且当前栈帧的方法所需要分配的最大长度是在编译时就确定了。局部变量表通过index来寻址,变量从index[0]开始传递。 局部变量表的数组中,每一个位置可以保存一个32位的数据类型:boolean、byte、char、short、int、float、reference或returnAddress类型的值。而对于64位的数据类型long和double则需要两个位置来存储,但是因为局部变量表是属于线程私有的,所以虽然被分割为2个变量存储,依然不用担心会出现安全性问题。 对于64位的数据类型,假如其占用了数组中的index[n]和index[n+1]两个位置,那么不允许单独访问其中的某一个位置,Java虚拟机规范中规定,如果出现一个64位的数据被单独访问某一部分时,则在类加载机制中的校验阶段就应该抛出异常。 Java虚拟机在方法调用时使用局部变量进行传递参数。在类方法(static方法)调用中,所有参数都以从局部变量中的index[0]开始进行参数传递。而在实例方法调用上,index[0]固定用来传递方法所属于的对象实例,其余所有参数则在从局部变量表内index[1]的位置开始进行传递。 注意:局部变量表中的变量不可以直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数才能使用。
(2)操作数栈(Operand Stacks)
操作数栈,在上下文语义清晰时,也可以称之为操作栈(Operand Stack),是一个后进先出(Last In First Out,LIFO)栈,同局部变量表一样,操作数栈的最大深度也是在编译时就确定的。 操作数栈在刚被创建时(也就是方法刚被执行的时候)是空的,然后在执行方法的过程中,通过虚拟机指令将常量/值从局部变量表或字段加载到操作数栈中,然后对其进行操作,并将操作结果压入栈内。 操作数堆栈上的每个条目都可以保存任何Java虚拟机类型的值,包括long或double类型的值。 注意:我们必须以适合其类型的方式对操作数堆栈中的值进行操作。例如,不可能将两个int类型的值压入栈后将其视为long类型,也不可能将两个float类型值压入栈内后使用iadd指令将其添加。
(3)动态连接(Dynamic Linking)
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。 在Class文件中的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种就称为静态解析。而另外一部分则会在每一次运行期间才会转化为直接引用,这部分就称为动态连接。
(4)方法返回地址
当一个方法开始执行后,只有两种方式可以退出:一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。 正常退出:如果对当前方法的调用正常完成,则可能会向调用方法返回一个值。当被调用的方法执行其中一个返回指令时,返回指令的选择必须与被返回值的类型相匹配(如果有的话)。方法正常退出时,当前栈帧通过将调用者的pc程序计数器适当的并跳过当前的调用指令来恢复调用程序的状态,包括它的局部变量表和操作数堆栈。然后继续在调用方法的栈帧来执行后续流程,如果有返回值的话则需要将返回值压入操作数栈。 异常终止:如果在方法中执行Java虚拟机指令导致Java虚拟机抛出异常,并且该异常没有在方法中处理,那么方法调用会突然结束,因为异常导致的方法突然结束永远不会有返回值返回给它的调用者。
(5)其他附加信息
这一部分具体要看虚拟机产商是如何实现的,虚拟机规范并没有对这部分进行描述。
2.JVM方法调用流程
(1)查看字节码
要想了解Java虚拟机的执行流程,那么我们必须要对类进行编译,得到字节码文件。将JVMDemo.class生成的字节码指令输出到1.txt文件中,然后打开,看到如下字节码指令:
javap -c xxx\xxx\JVMDemo.class >1.txt
package com.zwx.jvm;
public class JVMDemo {
public static void main(String[] args) {
int sum = add(1, 2);
print(sum);
}
public static int add(int a, int b) {
a = 3;
int result = a + b;
return result;
}
public static void print(int num) {
System.out.println(num);
}
}
Compiled from "JVMDemo.java"
public class com.zwx.jvm.JVMDemo {
public com.zwx.jvm.JVMDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_1
1: iconst_2
2: invokestatic #2 // Method add:(II)I
5: istore_1
6: iload_1
7: invokestatic #3 // Method print:(I)V
10: return
public static int add(int, int);
Code:
0: iconst_3
1: istore_0
2: iload_0
3: iload_1
4: iadd
5: istore_2
6: iload_2
7: ireturn
public static void print(int);
Code:
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iload_0
4: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
7: return
}
(2)字节码指令说明
iconst_i:表示将整型数字i压入操作数栈,注意,这里i的返回只有-1~5,如果不在这个范围会采用其他指令,如当int取值范围是[-128,127]时,会采用bipush指令。
invokestatic:表示调用一个静态方法
istore_n:这里表示将一个整型数字存入局部变量表的索引n位置,因为局部变量表是通过一个数组形式来存储变量的
iload_n:表示将局部变量位置n的变量压入操作数栈
ireturn:将当前方法的结果返回到上一个栈帧
invokevirtual:调用虚方法
(3)主要执行流程
(a)代码编译之后大致得到如下的一个Java虚拟机栈,注意这时候操作数栈都是空的(pc寄存器的值在这里暂不考虑 ,实际上调用指令的过程,pc寄存器是会一直发生变化的); (b)执行iconst_1和iconst_2两个指令,也就是从本地变量中把整型1和2两个数字压入操作数栈内; (c)执行invokestatic指令,调用add方法,会再次创建一个新的栈帧入栈,并且会将参数a和b存入add栈帧中的本地变量表; (d)add栈帧中调用iconst_3指令,从本地变量中将整型3压入操作数栈; (e)add栈帧中调用istore_0,表示将当前的栈顶元素存入局部变量表index[0]的位置,也就是赋值给a。 (f)调用iload_0和iload_1,将局部变量表中index[0]和index[1]两个位置的变量压入操作数栈 (g)最后执行iadd指令:将3和2弹出栈后将两个数相加,得到5,并将得到的结果5重新压入栈内 (h)执行istore_2指令,将当前栈顶元素弹出存入局部变量表index[2]的位置,并再次调用iload_2从局部变量表内将index[2]位置的数据压入操作数栈内 (i)最后执行ireturn命令将结果5返回main栈帧,此时栈帧add被销毁,回到main栈帧继续后续执行,方法的调用大致就是不断的入栈和出栈的过程。
(4)方法调用分析
Java是一种面向对象语言,支持多态,而多态的体现形式就是方法重载和方法重写,那么Java虚拟机又是如何确认我们应该调用哪一个方法的呢。
(a)方法调用指令
首先,我们来看一下方法的字节码调用指令,在Java中,提供了4种字节码指令来调用方法(jdk1.7之前)。注意:在JDK1.7开始,Java新增了一个指令invokedynamic,这个是为了实现“动态类型语言”而引入的,在这里我们暂不讨论。
invokestatic:调用静态方法
invokespecial:调用实例构造器方法,私有方法,父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法(运行时会确定一个实现了接口的对象)
(b)方法解析
在类加载机制中的解析阶段,主要做的事情就是将符号引用转为直接引用,但是,对方法的调用而言,有一个前提,那就是在方法真正运行之前就可以唯一确定具体要调用哪一个方法,而且这个方法在运行期间是不可变的。 只有满足这个前提的方法才会在解析阶段直接被替换为直接引用,否则只能等到运行时才能最终确定。
(c)非虚方法
在Java语言中,满足“编译器可知,运行期不可变”这个前提的方法,被称之为非虚方法。非虚方法在类加载机制中的解析阶段就可以直接将符号引用转化为直接引用。非虚方法有4种:
静态方法
私有方法
实例构造器方法
父类方法(通过super.xxx调用,因为Java是单继承,只有一个父类,所以可以确定方法的唯一)
除了非虚方法之外的非final方法就被称之为虚方法,虚方法需要运行时才能确定真正调用哪一个方法。Java语言规范中明确指出,final方法是一种非虚方法,但是final又属于比较特殊的存在,因为final方法和其他非虚方法调用的字节码指令不一样。 知道了虚方法的类型,再结合上面的方法的调用指令,我们可以知道,虚方法就是通过字节码指令invokestatic和invokespecial调用的,而final方法又是一个例外,final方法是通过字节码指令invokevirtual调用的,但是因为final方法的特性就是不可被重写,无法覆盖,所以必然是唯一的,虽然调用指令不同,但是依然属于非虚方法的范畴。
(d)方法重载
先来看一个方法重载的例子,这里Java虚拟机为什么会选择参数为Human的方法来进行调用呢?在解释这个问题之前,我们先来介绍一个概念:宗量。
package com.zwx.jvm.overload;
public class OverloadDemo {
static class Human {
}
static class Man extends Human {
}
static class WoMan extends Human {
}
public void hello(Human human) {
System.out.println("Hi,Human");
}
public void hello(Man man) {
System.out.println("Hi,Man");
}
public void hello(WoMan woMan) {
System.out.println("Hi,Women");
}
public static void main(String[] args) {
OverloadDemo overloadDemo = new OverloadDemo();
Human man = new Man();
Human woman = new WoMan();
overloadDemo.hello(man);
overloadDemo.hello(woman);
}
}
输出结果为:
Hi,Human
Hi,Human
(e)宗量
方法的接收者(调用者)和方法参数统称为宗量。而最终决定方法的分派就是基于宗量来选择的,故而根据基于多少种宗量来选择方法又可以分为:
单分派:根据1个宗量对方法进行选择
多分派:根据1个以上的宗量对方法进行选择
知道了方法的分派是基于宗量来进行的,那我们再回到上面的例子中就很好理解了。 overloadDemo.hello(man);这句代码中overloadDemo表示接收者,man表示参数,而接收者是确定唯一的,就是overloadDemo实例,所以决定调用哪个方法的只有参数(包括参数类型和个数和顺序)这一个宗量。我们再看看参数类型: Human man = new Man();这句话中,Human称之为变量的静态类型,而Man则称之为变量的实际类型,而Java虚拟机在确认重载方法时是基于参数的静态类型来作为判断依据的,故而最终实际上不管你右边new的对象是哪个,调用的都是参数类型为Human的方法。
(f)静态分派
所有依赖变量的静态类型来定位方法执行的分派动作就称之为静态分派。静态分派最典型的应用就是方法重载。 方法重载在编译期就能确定方法的唯一,不过虽然如此,但是在有些情况下,这个重载版本不是唯一的,甚至是有点模糊的。 产生这个原因就是因为字面量并不需要定义,所以字面量就没有静态类型,比如我们直接调用一个方法:xxx.xxx(‘1’),这个字面量1就是模糊的,并没有对应静态类型。
(g)方法重写
这里静态类型都是Human,但是却输出了两种结果,所以肯定不是按照静态类型来分派方法了,而从结果来看应该是按照了调用者的实际类型来进行的判断。
package com.zwx.jvm.override;
public class OverrideDemo {
static class Human {
public void hello(Human human) {
System.out.println("Hi,Human");
}
}
static class Man extends Human {
@Override
public void hello(Human human) {
System.out.println("Hi,Man");
}
}
static class WoMan extends Human {
@Override
public void hello(Human human) {
System.out.println("Hi,Women");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new WoMan();
man.hello(man);
man.hello(woman);
woman.hello(woman);
woman.hello(man);
}
}
输出结果为:
Hi,Man
Hi,Man
Hi,Women
Hi,Women
执行javap命令把类转换成字节码,方法调用使用了指令invokevirtual来调用,因为根据上面的分类可以判断,hello方法均是虚方法。 方法调用者和参数压入操作数栈,然后调用invokevirtual指令调用方法。 所以上面最关键的就是invokevirtual指令到底是如何工作的呢?invokevirtual主要是按照如下步骤进行方法选择的:
找到当前操作数栈中的方法接收者(调用者),记下来,比如叫Caller
然后在类型Caller中去找方法,如果找到方法签名一致的方法,则停止搜索,开始对方法校验,校验通过直接调用,校验不通过,直接抛IllegalAccessError异常
如果在Caller中没有找到方法签名一致的方法,则往上找父类,以此类推,直到找到为止,如果到顶了还没找到匹配的方法,则抛出AbstractMethodError异常
(h)动态分派
上面的方法重写例子中,在运行期间才能根据实际类型来确定方法的执行版本的分派过程就称之为动态分派。
(i)单分派与多分派
上面方法重载的第1个示例中,是一个静态分派过程,静态分配过程中Java虚拟机选择目标方法有两点:
静态类型
方法参数
也就是用到了2个宗量来进行分派,所以是一个静态多分派的过程。 而上面方法重写的例子中,因为方法签名是固定的,也就是参数是固定的,那么就只有一个宗量-静态类型,能最终确定方法的调用,所以属于动态单分派。 所以可以得出对Java而言:Java是一门静态多分派,动态单分派语言
JVM调优
1.你在项目中都使用了哪些参数打印GC?
具体的参数名称记不清楚了,但是我一般在项目中输出详细的GC日志,并加上可读性强的GC日志的时间戳。 特别情况下我还会追加一些反映对象晋升情况和堆详细信息的日志,这些会单独打到gc.log文件中用来排查问题。 另外,OOM时自动Dump堆栈,我一般也会进行配置。
2.常用的调优工具有哪些?
JDK内置的命令行:
jps(查看jvm进程信息)。
jstat(监视jvm运行状态的,比如gc情况、jvm内存情况、类加载情况等)。
jinfo(查看jvm参数的,也可动态调整)。
jmap(生成dump文件的,在dump的时候会影响线上服务)。
jhat(分析dump的,但是一般都将dump导出放到mat上分析)。
jstack(查看线程的)。
JDK内置的可视化界面:
JConsole
JVisualVM,这两个在QA环境压测的时候很有用。
阿里巴巴开源的arthas:神器,线上调优很方便,安装和显示效果都很友好。
3.如果有一个系统,内存一直消耗不超过10%,但是观察GC日志,发现FGC总是频繁产生,会是什么引起的?
检查下系统是否存在System.gc() ;
4.线上一个系统跑一段时间就栈溢出了,怎么办?
(1)首先检查下是否有死归这种无限递归的程序或者递归方法太多。 (2)可以看下栈大小,若太小则可以指定-Xss参数设置栈大小。
5.系统CPU经常100%,如何调优?
CPU100%,那肯定是有线程一直在占用着系统资源,所以具体方法如下:
找出哪个进程cpu占用高(top命令)。
该进程中的哪个线程cpu占用高(top -Hp $pid命令)。
将十进制的tid转化为十六进制(printf %x $tid命令)。
导出该线程的堆栈 (jstack $pid >$pid.log命令)。
查找哪个方法(栈帧)消耗时间 (less $pid.log)。
可以确认工作线程占比高还是垃圾回收线程占比高。
修改代码。
6.系统内存飙高,如何查找问题?
找出哪个进程内存占用高(top命令)
查看jvm进程号(jps命令)
导出堆内存 (jmap命令生成dump文件,注意:线上系统,内存特别大,jmap执行期间会对进程产生很大影响,甚至卡顿,所以操作前最好先从负载均衡里摘掉。)
分析dump文件 (比如mat软件)
7.大型项目如何进行性能瓶颈调优
(1)数据库与SQL优化:一般dba负责数据库优化,比如集群主从等。研发负责SQL优化,比如索引、分库分表等。
(2)集群优化:一般OP负责,让整个集群可以很容易的水平扩容,再比如tomcat/nginx的一些配置优化等。
(3)硬件升级:选择最合适的硬件,充分利用资源。
(4)代码优化:很多细节,可以参照阿里巴巴规范手册和安装sonar插件这种检测代码质量的工具。也可以适当的运用并行,比如CountDownLatch等工具。
(5)jvm优化:内存区域大小设置、对象年龄达到次数晋升老年代参数的调整、选择合适的垃圾收集器以及合适的垃圾收集器参数、打印详细的GC日志和oom的时候自动生成dump。
(6)操作系统优化
8.GC参数
GC常用参数
-Xmn:年轻代
-Xms:最小堆
-Xmx :最大堆
-Xss:单个线程栈空间大小
-XX:+UseTLAB:使用TLAB,默认打开
-XX:+PrintTLAB:打印TLAB的使用情况
-XX:TLABSize:设置TLAB大小
-XX:+DisableExplictGC:禁用System.gc()不管用 ,防止FGC
-XX:+PrintGC:打印GC日志
-XX:+PrintGCDetails:打印GC详细日志信息
-XX:+PrintHeapAtGC:打印GC前后的详细堆栈信息
-XX:+PrintGCTimeStamps:打印时间戳
-XX:+PrintGCApplicationConcurrentTime:打印应用程序时间
-XX:+PrintGCApplicationStoppedTime:打印暂停时长
-XX:+PrintReferenceGC:记录回收了多少种不同引用类型的引用
-XX:+PrintVMOptions:jvm参数
-XX:+PrintFlagsFinal:-XX:+PrintFlagsInitial 必须会用
-Xloggc:opt/log/gc.log:gc日志的路径以及文件名称
-XX:MaxTenuringThreshold:升代年龄,最大值15
-XX:+HeapDumpOnOutOfMemoryError OOM时dump
-XX:HeapDumpPath=D:\dump 堆Dump文件路径
-XX:-UseGCOverheadLimit 取消GC开销检查
-XX:MetaspaceSize=5m 元数据区大小
-XX:MaxMetaspaceSize=5m 元数据区最大大小
-XX:MaxDirectMemorySize 直接内存容量
-verbose:class:控制台打印类加载详细过程
-verbose:gc 在控制台输出GC情况
Parallel常用参数
-XX:SurvivorRatio:年轻代中eden和from/to的比值。比如设置3就是eden:survivor=3:2,也就是from和to各占1,eden占用3
-XX:PreTenureSizeThreshold:大对象到底多大
-XX:MaxTenuringThreshold:升代年龄,最大值15
-XX:+ParallelGCThreads:并行收集器的线程数,同样适用于CMS,一般设为和CPU核数相同
-XX:+UseAdaptiveSizePolicy:自动选择各区大小比例
CMS常用参数
-XX:+UseConcMarkSweepGC:设置年老代为并发收集
-XX:ParallelCMSThreads:CMS线程数量
-XX:CMSInitiatingOccupancyFraction:使用多少比例的老年代后开始CMS收集,默认是68%(近似值),如果频繁发生SerialOld卡顿,应该调小,(频繁CMS回收)
-XX:+UseCMSCompactAtFullCollection:在FGC时进行压缩
-XX:CMSFullGCsBeforeCompaction:多少次FGC之后进行压缩
-XX:+CMSClassUnloadingEnabled:年老代启用CMS,但默认是不会回收永久代(Perm)的。此处对Perm区启用类回收,防止Perm区内存满。
-XX:CMSInitiatingPermOccupancyFraction:达到什么比例时进行Perm回收
GCTimeRatio:设置GC时间占用程序运行时间的百分比
-XX:MaxGCPauseMillis:停顿时间,是一个建议时间,GC会尝试用各种手段达到这个时间,比如减小年轻代
G1常用参数
-XX:+UseG1GC:开启G1
-XX:MaxGCPauseMillis:建议值,G1会尝试调整Young区的块数来达到这个值
-XX:GCPauseIntervalMillis:GC的间隔时间
-XX:+G1HeapRegionSize:分区大小,建议逐渐增大该值,1 2 4 8 16 32。随着size增加,垃圾的存活时间更长,GC间隔更长,但每次GC的时间也会更长 ZGC做了改进(动态区块大小)
G1NewSizePercent:新生代最小比例,默认为5%
G1MaxNewSizePercent:新生代最大比例,默认为60%
GCTimeRatio:GC时间建议比例,G1会根据这个值调整堆空间
ConcGCThreads:线程数量
InitiatingHeapOccupancyPercent:启动G1的堆空间占用比例