第一部分:Java体系
- Java体系结构介绍
- Java虚拟机缩写为JVM,作用执行类文件。
- Java API类库中的JavaSE API子集 和Java虚拟机这两部分统称为JRE(Java Runtime Environment),也称为Java运行时标准环境。
- Java程序设计语言、Java虚拟机、Java API类库这三部分统称为JDK(Java Development Kit),JDK是用于支持Java程序开发的最小环境。
- 本节面试题
- 什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?
Java虚拟机是一个可以执行Java字节码的虚拟机进程。
Java源文件被编译成能被Java虚拟机执行的字节码文件。 Java被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。
- 什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?
第二部分:自动内存管理机制
- 运行时数据区域
- 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。 - 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。 - 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
【关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。】 - Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
所有的对象实例以及数组都要在堆上分配。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。
从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代; 再细致一点新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
先分清楚到底是出现了内存泄漏还是内存溢出:处理Java堆内存问题的简单思路
如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置。
如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。 - 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
很多人都更愿意把方法区称为“永久代”(Permanent Generation)
这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
常量:被final修饰的变量,不可变,存储在方法区的常量池中。
全局常量:被final和static 修饰符修饰的变量,也称为常量,它属于类,不属于类的任何一个对象。
静态变量:static修饰符修饰的变量,也称为类变量,它属于类,不属于类的任何一个对象,一个类不管创建多少个对象,静态变量在内存中有且仅有一个拷贝;静态变量可以实现让多个对象共享内存。
实例变量:实例变量必须依存于某一实例,需要先创建对象然后通过对象才能访问到它。 - 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。(字面量相当于Java语言层面常量的概念,比如:字符串常量、声明为final的常量等等。)
只有通过new String("");方式创建的字符串才会放在堆中;如果是通过String str = "abc";这样的方式创建的字符串会在编译器就放在字符串池中(常量池中)
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。String str = new String("hello"); 上面的语句中变量str放在栈上,用new创建出来的字符串对象放在堆上,而"hello"这个字面量放在方法区。 所以 String s1="hello"; System.out.println(str==s1);//false
String 类型的常量池比较特殊。它的主要使用方法有两种:
也就是说直接使用双引号声明出来的 String 对象会直接存储在常量池中。•如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
字符串拼接 - 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
- 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
- HotSpot虚拟机对象探秘
- 对象的创建
- 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程(类加载过程在后边讲)
- 为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,最终常用的办法“本地线程缓冲分配(TLAB)
- 将除对象头外的对象内存空间初始化为0
- 对对象头进行必要设置。
例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 对象的内存布局
- 对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。
- 实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)
- 对齐填充:JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)
- 对象的访问定位
- 句柄池、直接指针。
- 对象的创建
- 垃圾回收算法
- 标记-清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。 - 复制算法
为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor 。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
内存的分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。 - 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。 - 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
- 标记-清除算法
- 垃圾收集器
- Serial收集器:串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。
- ParNew收集器:ParNew收集器其实就是Serial收集器的多线程版本。
- Parallel Scavenge收集器:Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。
- Serial Old收集器:
- Parallel Old收集器:Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法
- CMS收集器:CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
- G1收集器:G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征
- 内存分配与回收策略
年轻代分为Eden区和survivor区(两块儿:from和to),且Eden:from:to==8:1:1。老年代内存比新生代也大很多(大概比例是2:1)- 大多数情况下,对象在新生代Eden区中分配(除非配置了-XX:PretenureSizeThreshold,大于该值的对象会直接进入年老代)。当Eden区满了或放不下了,虚拟机将发起一次Minor GC,这时候其中存活的对象会复制到from区,这里需要注意的是,如果存活下来的对象from区都放不下,则这些存活下来的对象全部进入年老代。之后Eden区的内存全部回收掉。
- 之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了,虚拟机将发起一次Minor GC,这时候将会把Eden区和from区存活下来的对象复制到to区(同理,如果存活下来的对象to区都放不下,则这些存活下来的对象全部进入老年代),之后回收掉Eden区和from区的所有内存。
- 之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了,虚拟机将发起一次Minor GC,这时候将会把Eden区和to区存活下来的对象复制到from区(同理,如果存活下来的对象from区都放不下,则这些存活下来的对象全部进入老年代),之后回收掉Eden区和to区的所有内存。
- 如上2,3步骤循环这样,会有很多对象会被复制很多次(每复制一次,对象的年龄就+1),默认情况下,当对象被复制了15次(这个次数可以通过:-XX:MaxTenuringThreshold来配置),就会进入年老代了。
- 当老年代满了或者存放不下将要进入年老代的存活对象的时候,就会发生一次Full GC(这个是我们最需要减少的,因为耗时很严重)。
- 注意:以下几种情况对象直接在分配在老年代
- 大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的byte[]数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。 - 长期存活的对象将进入老年代
第4步:如上这样,会有很多对象会被复制很多次(每复制一次,对象的年龄就+1),默认情况下,当对象被复制了15次(这个次数可以通过:-XX:MaxTenuringThreshold来配置),就会进入年老代了。 - 动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
- 大对象直接进入老年代
- 说明:年轻代分为Eden区和survivor区,老年区Tenured
- 伊甸园(Eden):这是对象最初诞生的区域,并且对大多数对象来说,这里是它们唯一存在过的区域。
- 幸存者乐园(Survivor):从伊甸园幸存下来的对象会被挪到这里。
- 终身颐养园(Tenured):这是足够老的幸存对象的归宿。年轻代收集(Minor-GC)过程是不会触及这个地方的。当年轻代收集不能把对象放进终身颐养园时,就会触发一次完全收集(Major-GC),这里可能还会牵扯到压缩,以便为大对象腾出足够的空间。 与垃圾回收相关的JVM参数:
- 垃圾回收有两种类型:Minor GC和Full GC有什么不一样吗?
- 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
- 本节面试题
- Java内存区域
- 说一下 jvm 运行时数据区?说一下 jvm 的主要组成部分?及其作用?
- 说一下堆栈的区别?
- 队列和栈是什么?有什么区别?
- 栈内存溢出出现的情况
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
解决:需要使用参数 -Xss 去调整JVM栈的大小 - 讲讲什么情况下会出现堆内存溢出?
Java堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
解决:通过参数-XX: +HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆 转储快照以便事后进行分析。 - Java 中会存在内存泄漏吗,请简单描述。
理论上Java因为有垃圾回收机制(GC)不会存在内存泄露问题(这也是Java被广泛使用于服务器端编程的一个重要原因);然而在实际开发中,可能会存在无用但可达的对象,这些对象不能被GC回收,因此也会导致内存泄露的发生。例如Hibernate的Session(一级缓存)中的对象属于持久态,垃圾回收器是不会回收这些对象的,然而这些对象中可能存在无用的垃圾对象,如果不及时关闭(close)或清空(flush)一级缓存就可能导致内存泄露。
- 对象已死了吗?
- 判断对象是否存活一般有两种方式?
- 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
- 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象。
- 怎么判断对象是否可以被回收?
- 该对象没有与GC Roots相连
- 该对象没有重写finalize()方法或finalize()已经被执行过则直接回收(第一次标记)、否则将对象加入到F-Queue队列中(优先级很低的队列)在这里finalize()方法被执行,之后进行第二次标记,如果对象仍然应该被GC则GC,否则移除队列。 (在finalize方法中,对象很可能和其他 GC Roots中的某一个对象建立了关联,finalize方法只会被调用一次,且不推荐使用finalize方法)
- java 中都有哪些引用类型?Java四种引用类型
- 强引用:GC时不会被回收
- 软引用:描述有用但不是必须的对象,在发生内存溢出异常之前被回收
- 弱引用:描述有用但不是必须的对象,在下一次GC时被回收
- 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用PhantomReference实现虚引用,虚引用用来在GC时返回一个通知。
- 判断对象是否存活一般有两种方式?
- 垃圾回收算法
- 说一下 jvm 有哪些垃圾回收算法?
- 垃圾回收器
- 说一下 jvm 有哪些垃圾回收器?各自优缺点?重点CMS和G1
- CMS的回收步骤
- G1和CMS的区别
- CMS哪个阶段是并发的哪个阶段是串行的?
- G1内部是如何分区的(region)
- 新生代垃圾回收器和老生代垃圾回收器都有哪些?有什么区别?
- 简述分代垃圾回收器是怎么工作的?
- 内存分配和回收策略
- JVM垃圾回收机制,何时出发minorGC等操作?
- JVM中一次完整的GC流程(用ygc到fgc)是怎样的?
- Minor GC与Full GC分别在什么时候发生?
- JVM年轻代到老年代的晋升过程的判断条件是什么?
- JVM出现fullGC很频繁,怎么去线上排查?
- 是不是频繁创建了大对象(也有可能eden区设置过小)(大对象直接分配在老年代中,导致老年代空间不足--->从而频繁gc)
- 是不是老年代的空间设置过小了(Minor GC几个对象就大于老年代的剩余空间了)
- GC是什么?为什么要有GC?
GC是垃圾收集的意思,内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java提供的GC功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java语言没有提供释放已分配内存的显示操作方法。Java程序员不用担心内存管理,因为垃圾收集器会自动进行管理。要请求垃圾收集,可以调用下面的方法之一:System.gc() 或Runtime.getRuntime().gc() ,但JVM可以屏蔽掉显示的垃圾回收调用。
垃圾回收可以有效的防止内存泄露,有效的使用可以使用的内存。垃圾回收器通常是作为一个单独的低优先级的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收。在Java诞生初期,垃圾回收是Java最大的亮点之一,因为服务器端的编程需要有效的防止内存泄露问题,然而时过境迁,如今Java的垃圾回收机制已经成为被诟病的东西。移动智能终端用户通常觉得iOS的系统比Android系统有更好的用户体验,其中一个深层次的原因就在于Android系统中垃圾回收的不可预知性。 - 为什么新生代中要有Survivor区?
防止频繁触发FULL GC。如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,这样会使老年代很快被填满,导致老年代触发FULL GC,由于老年代的内存空间远大于新生代,所以进行一次Full GC消耗的时间比Minor GC长得多。 - 为什么要设置两个Survivor区?
防止产生内存空间碎片。如果只有Survivor1,那么每一次当Eden区满时,触发Minor GC并把对象移入Survivor1中,如此循环对导致Survivor1中产生大量的空间碎片;所以需要有Survivor2,当Eden再一次满时,触发Minor GC,虚拟机会把 Eden中和Survivor1中的存活对象通过复制算法移入Survivor2中,这样Survivor2就不会产生内存碎片,同时Eden和Survivor1会清理内存,保证下一次Minor GC触发时的操作。
- 说一下 jvm 调优的工具?
常用调优工具分为两类,jdk自带监控工具:jconsole和jvisualvm,第三方有:MAT(Memory Analyzer Tool)、GChisto。- jconsole,Java Monitoring and Management Console是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存,线程和类等的监控
- visualVM,jdk自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC变化等。
- MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
- GChisto,一款专业分析gc日志的工具
- GC日志分析
- 与垃圾回收相关的JVM参数:
- Xss——栈容量大小
- Xms / -Xmx — 堆的初始大小 / 堆的最大大小
- XX:NewRatio — 可以设置老生代和新生代的比例
- Xmn — 堆中年轻代的大小
- XX:NewSize / XX:MaxNewSize — 设置新生代大小/新生代最大大小
- XX:InitialTenuringThreshold / -XX:MaxTenuringThreshold:设置老年代阀值的初始值和最大值
- XX:-DisableExplicitGC — 让System.gc()不产生任何作用
- XX:+PrintGCDetails — 打印GC的细节
- XX:+PrintGCDateStamps — 打印GC操作的时间戳
- XX:PrintTenuringDistribution — 设置每次新生代GC后输出幸存者乐园中对象年龄的分布
- XX:TargetSurvivorRatio:设置幸存区的目标使用率
- jvm 调优
- 第一步:选择一款调优工具
- 第二步:分析jvm内存分布情况
- 第三步:分析gc日志
- 第四步:设置合适的与垃圾回收相关的参数
- Java内存区域
第三部分:虚拟机执行子系统
- Class类文件的结构
- 魔数与Class文件的版本
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。 - 常量池
紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。 - 访问标志
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。 - 类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。 - 字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。 - 方法表集合
Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项, - 属性表集合
属性表(attribute_info)在前面的讲解之中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。 - 字节码指令简介
- 魔数与Class文件的版本
- 类加载的时机
- 什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:
使用new关键字实例化对象的时候、
读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,
以及调用一个类的静态方法的时候。 - 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
解析:Java父子类加载顺序加载顺序:
父类静态属性 > 父类静态代码块 > 子类静态属性 > 子类静态代码块 > 父类非静态属性 > 父类非静态代码块 > 父类构造器 > 子类非静态属性 > 子类非静态代码块 > 子类构造器
小结几个特点:-
静态属性和代码块,当且仅当该类在程序中第一次被 new 或者第一次被类加载器调用时才会触发(不考虑永久代的回收)。也正是因为上述原因,类优先于对象 加载/new,即 静态优先于非静态。
-
属性(成员变量)优先于构造方法,可以这么理解,加载这整个类,需要先知道类具有哪些属性,并且这些属性初始化完毕之后,这个类的对象才算是完整的。另外,非静态代码块其实就是对象 new 的准备工作之一,算是一个不接受任何外来参数的构造方法。因此,属性 > 非静态代码块 > 构造方法。
-
有趣的是,静态部分(前4个)是父类 > 子类,而 非静态部分(后6个)也是父类 > 子类。
-
另外容易忽略的是,非静态代码块在每次 new 对象时都会运行,可以理解:非静态代码块是正式构造方法前的准备工作(非静态代码块 > 构造方法)。
测试:public class Main { static class A { static Hi hi = new Hi("A"); Hi hi2 = new Hi("A2"); static { System.out.println("A static"); } { System.out.println("AAA"); } public A() { System.out.println("A init"); } } static class B extends A { static Hi hi = new Hi("B"); Hi hi2 = new Hi("B2"); static { System.out.println("B static"); } { System.out.println("BBB"); } public B() { System.out.println("B init"); } } static class Hi { public Hi(String str) { System.out.println("Hi " + str); } } public static void main(String[] args) { System.out.println("初次 new B:"); B b = new B(); System.out.println(); System.out.println("第二次 new B:"); b = new B(); } } 打印结果: 初次 new B: Hi A A static Hi B B static Hi A2 AAA A init Hi B2 BBB B init 第二次 new B: Hi A2 AAA A init Hi B2 BBB B init
-
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
除此之外,所有引用类的方式都不会触发初始化,称为被动引用。- 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
- SuperClass[] sca=new SuperClass[10];,并不会初始化SuperClass类
- 虽然在Java源码中引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”存储到了NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用实际都被转化为NotInitialization类对自身常量池的引用了。也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:
- 什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 类加载的过程:Java虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化这5个阶段所执行的具体动作。
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制- 加载
“加载”是“类加载”(Class Loading)过程的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,虚拟机需要完成以下3件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象(即类对象),作为方法区这个类的各种数据的访问入口。
对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。 - 验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 - 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值。
实例解析:
(1).假设一个类变量的定义为:public static int value=123;
那变量value在准备阶段过后的初始值为0而不是123.因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
至于“特殊情况”是指:public static final int value=123,即当类字段的字段属性是ConstantValue时,会在准备阶段初始化为指定的值,所以标注为final之后,value的值在准备阶段初始化为123而非0.)
看下面的面试题就明白了class Singleton{ private static Singleton singleton = new Singleton(); public static int value1; public static int value2 = 0; private Singleton(){ value1++; value2++; } public static Singleton getInstance(){ return singleton; } } class Singleton2{ public static int value1; public static int value2 = 0; private static Singleton2 singleton2 = new Singleton2(); private Singleton2(){ value1++; value2++; } public static Singleton2 getInstance2(){ return singleton2; } } public static void main(String[] args) { Singleton singleton = Singleton.getInstance(); System.out.println("Singleton1 value1:" + singleton.value1); System.out.println("Singleton1 value2:" + singleton.value2); Singleton2 singleton2 = Singleton2.getInstance2(); System.out.println("Singleton2 value1:" + singleton2.value1); System.out.println("Singleton2 value2:" + singleton2.value2); }
运行的结果: 要注意调用的静态构造函数和是静态变量的先后顺序,然后按此顺序赋值
Singleton1 value1 : 1
Singleton1 value2 : 0
Singleton2 value1 : 1
Singleton2 value2 : 1
Singleton输出结果:1 0
原因:
1 首先执行main中的Singleton singleton = Singleton.getInstance();
2 类的加载:加载类Singleton
3 类的验证
4 类的准备:为静态变量分配内存,设置默认值。这里为singleton(引用类型)设置为null,value1,value2(基本数据类型)设置默认值0
5 类的初始化(按照赋值语句进行修改):
执行private static Singleton singleton = new Singleton();
执行Singleton的构造器:value1++;value2++; 此时value1,value2均等于1
执行
public static int value1;
public static int value2 = 0;
此时value1=1,value2=0Singleton2输出结果:1 1
原因:1 首先执行main中的Singleton2 singleton2 = Singleton2.getInstance2();
2 类的加载:加载类Singleton2
3 类的验证
4 类的准备:为静态变量分配内存,设置默认值。这里为value1,value2(基本数据类型)设置默认值0,singleton2(引用类型)设置为null,
5 类的初始化(按照赋值语句进行修改):
执行
public static int value2 = 0;
此时value2=0(value1不变,依然是0);
执行
private static Singleton singleton = new Singleton();
执行Singleton2的构造器:value1++;value2++;
此时value1,value2均等于1,即为最后结果 - 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。 - 初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。- 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
public class Test{ static{ i=0;//给变量赋值可以正常编译通过 System.out.print(i);//这句编译器会提示"非法向前引用",不能访问i } static int i=1; }
- 父类中定义的静态语句块要优先于子类的变量赋值操作
public class Parent { public static int A=1; static { A=2; } } public class Sub extends Parent{ public static int B=A; public static void main(String[] args) { System.out.println(A);//2 } }
- 只有当父接口中定义的变量使用时,父接口才会初始化。
- 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
- 加载
- 类加载器
- 类与类加载器
- 什么是类加载器?虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
(类对象,就是用于描述这种类,都有什么属性,什么方法的) - 类加载器的作用
- 加载类
- 可用于确定类在Java虚拟机中的唯一性(对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。)
- 什么是类加载器?虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
- 双亲委派模型
- 类加载器种类
- 启动类加载器,Bootstrap ClassLoader,加载<JAVA_HOME>\jre\lib,或者被-Xbootclasspath参数限定的类
- 扩展类加载器,Extension ClassLoader,加载<JAVA_HOME>\jre\lib\ext,或者被java.ext.dirs系统变量指定的类
- 应用程序类加载器,Application ClassLoader,加载ClassPath中的类库
System.out.println(System.getProperty("java.class.path")); // 这样可以查看当前项目中使用应用程序类加载的class有哪些 - 自定义类加载器,通过继承ClassLoader实现,一般是加载我们的自定义类
- 双亲委派模型
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。 - 双亲委派好处
- 避免同一个类被多次加载,(使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。)
- 双亲委派模型对于保证Java程序的稳定运作。
- 类加载器种类
- Tomcat类加载架构:
Tomcat目录下有4组目录:- /common目录下:类库可以被Tomcat和Web应用程序共同使用;由 Common ClassLoader类加载器加载目录下的类库;
- /server目录:类库只能被Tomcat可见;由 Catalina ClassLoader类加载器加载目录下的类库;
- /shared目录:类库对所有Web应用程序可见,但对Tomcat不可见;由 Shared ClassLoader类加载器加载目录下的类库;
- /WebApp/WEB-INF目录:仅仅对当前web应用程序可见。由 WebApp ClassLoader类加载器加载目录下的类库;
- 每一个JSP文件对应一个JSP类加载器。
- 类与类加载器
- 本节面试题
- 说一下类加载的时机
- JVM中的类卸载的时机?
类的卸载跟采用的垃圾收集算法有关。JVM中一个类的卸载要满足下面这3个条件:- 该类所有的实例对象都已被回收;
- 该类的类加载器对象已经被回收;
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
- 说一下类加载的执行过程
- 类的实例化顺序?
- 为什么String.class的类加载器为null
因为在java中rt.jar包里的类都是引导类加载器(bootstrap class loader)加载的,这个加载器由于是C++写的,所以在java中会为null - 什么是双亲委派模型?类加载为什么要使用双亲委派模式,有没有什么场景是打破了这个模式?
什么情况下会破坏双亲委派模型?为什么?可否举个例子?
双亲委托模型的重要用途是为了解决类载入过程中的安全性问题。
假设有一个开发者自己编写了一个名为java.lang.Object
的类,想借此欺骗JVM。现在他要使用自定义ClassLoader
来加载自己编写的java.lang.Object
类。
然而幸运的是,双亲委托模型不会让他成功。因为JVM会优先在Bootstrap ClassLoader
的路径下找到java.lang.Object
类,并载入它
Java的类加载是否一定遵循双亲委托模型?
在实际开发中,我们可以通过自定义ClassLoader,并重写父类的loadClass方法,来打破这一机制。 - Tomcat中的类加载机制有了解吗?为什么这么设计?
- 实际开发中有遇到哪些类加载器相关的问题?你又是如何解决的?
- 描述一下JVM加载class文件的原理机制
- 一看你就懂,超详细java中的ClassLoader详解:https://frank909.blog.csdn.net/article/details/54973413
- 类加载器ClassLoader详解:类加载器ClassLoader详解_zuodaoyong的博客-CSDN博客_classloader
第四部分:代码编译和优化
第五部分:高效并发
- CPU内存模型:CPU内存模型、Java内存模型和JVM内存模型_The Leaner的博客-CSDN博客_cpu内存模型
- Java内存模型和线程
- 主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的 ,不会被共享,自然就不会存在竞争问题。 - 内存间交互操作
8种操作- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
- 对于volatile型变量的特殊规则
- 第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。
注意:volatile变量的运算在并发下一样是不安全的。/** *volatile变量自增运算测试 * *@author zzm */ public class VolatileTest{ public static volatile int race=0; public static void increase(){ race++; } private static final int THREADS_COUNT=20; public static void main(String[]args){ Thread[]threads=new Thread[THREADS_COUNT]; for(int i=0;i<THREADS_COUNT;i++){ threads[i]=new Thread(new Runnable(){ @Override public void run(){ for(int i=0;i<10000;i++){ increase(); } } }); threads[i].start(); } //等待所有累加线程都结束 while(Thread.activeCount()>1) Thread.yield(); System.out.println(race); } } //输出结果小于200000
- 使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
Map configOptions; char[] configText; //此变量必须定义为volatile volatile boolean initialized=false; //假设以下代码在线程A中执行 //模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用 configOptions=new HashMap(); configText=readConfigFile(fileName); processConfigOptions(configText,configOptions); initialized=true; //假设以下代码在线程B中执行 //等待initialized为true,代表线程A已经把配置信息初始化完成 while(!initialized){ sleep(); } //使用线程A中初始化好的配置信息 doSomethingWithConfig(); 结果:如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化, 导致位于线程A中最后一句的代码“initialized=true”被提前执行, 这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生 。
- 何时使用volatile,何时使用锁?唯一原则:不能用volatile,就加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。不符合以下两条规则的运算场景中就加锁
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
- 第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。
- 对于long和double型变量的特殊规则
允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,这点就是所谓的long和double的非原子性协定
(如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。)
注意:虽然long和double型变量不符合非原子性协定,但大多数情况还是把64位数据的读写操作作为原子操作来对待,也就是在编写代码时一般不需要把用到的long和double变量专门声明为volatile - 原子性,可见性,有序性的区别
- 原子性:基本数据类型的访问读写是具备原子性的(例外就是long和double的非原子性协定)
- 可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。 - Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。
synchronized关键字在需要这3种特性的时候都可以作为其中一种的解决方案。
- Java与线程的关系
- 线程是CPU调度的基本单位
- Java线程的使用(看Java基础中的Java线程的实现、Java线程的状态)
- 主内存与工作内存
- 线程安全和锁优化
- 线程安全
- Java语言中的Java线程安全
- 不可变(final 关键字修饰,比如:String)
- 绝对线程安全
java.util.Vector是一个线程安全的容器,相信所有的Java程序员对此都不会有异议,因为它的add()、get()和size()这类方法都是被synchronized修饰的,尽管这样效率很低,但确实是安全的。 - 相对线程安全
在Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。 - 线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API中大部分的类都是属于线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。 - 线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
- 线程安全的实现方法
- 互斥同步:互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称
为阻塞同步(Blocking Synchronization)。
互斥同步是常见的一种并发正确性保障手段。
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。
而互斥是实现同步的一种手段,临界区(CriticalSection)、互斥量(Mutex)和信号量(Semaphore)(这3者需要弄明白)都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。- synchronized
- 第一步:synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
- 第二步:根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
- 在虚拟机规范对monitorenter和monitorexit的行为描述中,有两点是需要特别注意的。首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
- java.util.concurrent(下文称J.U.C)包中的重入锁(ReentrantLock)可以实现同步
- 在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来完成),另一个表现为原生语法层面的互斥锁。
- 不过,相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。
- 等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
- 锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。
- synchronized和ReentrantLock两者的选用?
- 在jdk1.6以前,多线程环境下synchronized的吞吐量下降得非常严重,而ReentrantLock则能基本保持在同一个比较稳定的水平上。与其说ReentrantLock性能好,还不如说synchronized还有非常大的优化余地。JDK 1.6中加入了很多针对锁的优化措施。
- JDK 1.6发布之后,人们就发现synchronized与ReentrantLock的性能基本上是完全持平了。因此,如果读者的程序是使用JDK 1.6或以上部署的话,性能因素就不再是选择ReentrantLock的理由了,虚拟机在未来的性能改进中肯定也会更加偏向于原生的synchronized,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。
- synchronized
- 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。
从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。
非阻塞同步(乐观锁)应该怎么实现?乐观锁实现的两种方式- 版本号控制
一般是在数据表中加上version字段,表示数据被修改的次数,当数据被修改时,version会加1。当线程A要更新数据时,会读取该数据中的version,等到操作完提交更新的时候,若刚才读取到的version值和当前数据库中的version值相等则更新数据库,否则重新更新操作,直到更新成功。 - CAS算法(读-修改-写操作),即compare and swap算法(比较和交换),在不适用锁的情况下实现多线程同步,也就是实现在没有线程被阻塞的情况下实现线程同步。
- 需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,一般情况下会一直自旋,不断的重试,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。
- 版本号控制
- 乐观锁的缺点
- ABA问题
如果一个变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍是A,那我们能说明这个A值没有被修改过吗?明显是不能的,这段时间它仍然可能是被改成其他值,然后又改回A,这就是ABA问题。
解决:JDK1.5之后的AtomicStampedReference类就可以解决,其中的compareAndSet方法就是首先检查当前引用是否等于预期引用,并且当前标识是否等于预期的标识(版本号),如果全部相等,则以原子的方式将该引用和该标识的值设置为拟写入的值。 - 循环时间开销大
自旋CAS(就是不成功就一直循环直到成功),如果长时间不成功,则会因为长期占有CPU而带了巨大的开销。 - 只能保证一个共享变量的原子操作
CAS只对单个共享变量有效,当操作涉及到跨多个共享变量时,CAS无效。
在jdk1.5后通过AtomicReference类可以将多个变量放入到一个对象中集体进行CAS操作。
- ABA问题
- 互斥同步:互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称
- Java语言中的Java线程安全
- 锁优化:为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
- 自旋锁与自适应的自旋锁
- 概念
前面我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。 - 在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。(如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。)
- 应用
- 虽然减少线程状态转换带开销,但是占用了处理器的时间,并且需要多处理器(目前的机器到都是使用的多处理,这个条件很容易满足)
- 自旋锁是不公平,无法满足等待时间最长的线程优先获取锁,会出现“饥饿线程”的问题。
- 自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 1.6中就已经改为默认开启了。
自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。
- 概念
- 锁消除
- 概念
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。 - 例子
由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK 1.5之前,会转化为StringBuffer对象(是线程安全的,有同步块)的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuilder对象的连续append()操作。
- 概念
- 锁粗化
- 概念
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。 - 例子
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,下面的代码,就是扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。
public String concatString(String s1,String s2,String s3){ StringBuffer sb=new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString();
- 概念
- 轻量级锁(重量级锁)
- 概念
轻量级锁是JDK 1.6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。 - 加锁执行过程
- 第一步:在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)
- 第二步:然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态。 - 第三步:如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈
帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
- 解锁执行过程
- 上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
- 应用
- 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
- 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
- 概念
- 偏向锁
- 概念
偏向锁也是JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步阻塞,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。 - 执行过程
如果读者读懂了前面轻量级锁中关于对象头Mark Word与线程之间的操作过程,那偏向锁的原理理解起来就会很简单。- 假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,这是JDK 1.6的默认值),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。
- 当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。
- 优缺点
- 偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。
- 概念
- 自旋锁与自适应的自旋锁
- 本节面试题
- volatiel关键字,volatile做什么用的,如何实现可见性的,volatile和atomic的区别,atomic底层是如何实现的
- Synchonized和Lock区别
- 偏向锁、自旋锁、轻量级锁、重量级锁
- 乐观锁和悲观锁
- 线程安全