JAVA重点知识汇总(包含Java基础JVMJava并发)
Posted oahaijgnahz
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JAVA重点知识汇总(包含Java基础JVMJava并发)相关的知识,希望对你有一定的参考价值。
文章目录
一、JAVA重点知识汇总
String的不可变性
1. String的不可变性(ps:通过反射可以改变)
- String类被final修饰,保证类不被继承。
- String内部
char[] value
设置为private,并且用final修饰符修饰,保证成员变量初始化后不被修改。 - 不提供setter方法改变成员变量,即避免外部通过其他接口修改String的值。
- 通过构造器初始化
char[] value
时,对传入对象进行深拷贝(deep copy),避免用户在String类以外通过改变这个对象的引用来改变其内部的值。 - 在getter方法中,不直接返回对象引用,而是返回对象的深拷贝,防止对象外泄。
2. String的不可变性的好处
- 满足字符串常量池的需要(有助于共享)。如果一个 String 对象已经被创建过了,那么就会从 String Pool 中取得引用。只有 String 是 不可变的,才可能使用 String Pool。
- 线程安全考虑
- 支持hash映射和缓存。因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。
3. 字符串的一些问题思考
-
new String(“aaa”)、s.intern()、String s = "aaa"的区别
String s1 = new String("aaa"); String s2 = new String("aaa"); System.out.println(s1 == s2); // false,指向堆内不同引用 String s3 = s1.intern(); String s4 = s1.intern(); System.out.println(s3 == s4); // true,指向字符串常量池中相同引用 String s5 = "bbb"; String s6 = "bbb"; System.out.println(s5 == s6); // true,指向字符串常量池中相同引用
只有使用引号包含文本的方式创建的String对象之间使用“+”连接产生的新对象才会被加入字符串池中。对于所有包含new方式新建对象(包括null)的“+”连接表达式,它所产生的新对象都不会被加入字符串池中而是在堆中非字符串常量池中存储。
-
字符串拼接
- "+"拼接:加号拼接字符串jvm底层其实是调用StringBuilder来实现的,但并不是说直接用“+”号拼接就可以达到StringBuilder的效率了,因为每次使用 "+"拼接都会新建一个StringBuilder对象,并且最后toString()方法还会生成一个String对象。在循环拼接次数较大时候,就会生成大量StringBuilder对象,会产生大量内存消耗。
- concat拼接:申请一个char类型的buf数组,将需要拼接的字符串都放在这个数组里,最后再创建并返回一个新的String对象。
-
String str = new String(“abc”)创建了几个对象?(两个对象)
一个是编译时期在字符串常量池中的"abc",另一个是运行时堆中(非常量池)的String对象。
StringBuilder & StringBuffer
在字符串修改/拼接时,String 是不可变的对象, 因此在每次对 String 类型进行改变的时候, 都会生成一个新的 String 对象,然后将指针指向新的 String 对象。不仅效率低下,还会大量浪费内存空间。
而使用 StringBuffer/StringBuilder 类时,每次都会对 StringBuffer/StringBuilder 对象本身中的char[]进行修改操作,而不产生新的未使用对象。
- 当字符串修改较少的情况下,建议使用
String str = 'hello'
来创建字符串- 当字符串修改较多的情况下,建议使用StringBuilder,在多线程的场景下建议使用StringBuffer(方法都通过synchronized来修饰保证并发修改的数据安全性)
"=="和equals的区别
- "=="的比较
- 基本数据类型用比较的是两个数据的值是否相等。
- 引用类型(类、接口、数组)用比较的是它们在内存中的存放地址是否相等(两个变量是否引用同一个对象)。
对象是存放在堆中的,栈中存放的是对象的引用(地址),所以直接对对象引用比较是在比较对象的栈中的值。如果要比较堆中对象的内容是否相同,那么就要重写equals方法了。
-
equals的比较
Object的equals(),源码实现中是对象地址的比较。通常,我们需要比较的是对象中的值,所以需要重写equals(),以让其按照我们想要的逻辑进行对象的比较(要注意传入对象类型的判断)。
ps:基本数据类型的包装类,在赋值、运算的时候会进行自动装箱和拆箱,直接进行==比较就是比较的包装对象的地址值
Object.hashCode()
hashCode方法返回一个hash码(int),主要作用是在对对象进行散列时作为key输入,因此需要每个对象的hashCode尽可能不同,这样才能保证散列的存取性能。事实上,Object类提供的默认实现确保每个对象的hash码不同(在对象的内存地址基础上经过特定算法返回一个hashCode)
hashCode用于配合基于散列的集合一起正常运行,这样的散列集合包括HashSet、HashMap 以及 HashTable。散列集合中元素不可重复,Java则依据元素的hashCode来判断两个元素是否重复。当集合要添加新的元素时,先调用这个元素的hashCode方法,就一下子能定位到它应该放置的物理位置上。如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了(放入对象的 hashcode与集合中任一元素的hashcode不相等);如果这个位置上已经有元素了(hashcode相等),就调用它的equals方法与新元素进行比较,相同的话就不存,不相同使用一定方法来解决hash冲突问题(经典的如链地址法)。
- 散列中如何判断对象是否相等?
//哈希值相等 && (同一个对象 || equals为true)
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) ...
- 等价的两个对象散列值一定相同
- 散列值相同的两个对象不一定等价(hash冲突)
Java的深浅拷贝
Java的深浅拷贝都属于对象拷贝。在对对象进行拷贝时,如果只对基本数据类型进行拷贝,而对引用数据类型进行了引用传递,则这个对象拷贝是浅拷贝;反之,在对对象的引用数据类型进行拷贝时,是通过创建了一个与原对象相同的新对象则称为深拷贝。
Objoct.clone()默认实现是浅拷贝,那么如何实现深拷贝?
- 重写clone()方法,在引用对象克隆时也调用其clone()方法,要求引用对象的clone方法也是深拷贝的。
- 将对象序列化,再反序列化得到一个与之相同的新对象(Serializable、解析成JSON等)。
多态与重载重写
Java三大特性:封装、继承、多态
多态的体现分为编译时多态和运行时多态。
重载是相对于同一个类来说的,同一个类中可以存在多个同名但是参数列表不同(返回值不参与)的函数,是Java编译时多态的体现。
重写是相对于继承的父类和子类来说的,子类通过拥有同名、同参数列表、同返回类型的函数来重写父类的方法,是Java运行时多态的体现。
方法调用的多态,是在类解析期间将符号引用转换成直接引用的过程中,在编译时期是静态分派的,调用的是引用类型中相同参数列表的对应方法(重载方法);而在运行时期,又会进行动态分派,根据实例对象中来确定调用哪个重写方法。
几个重要的关键字
- final:修饰变量则初始化后不可修改,修饰类变量则需要直接初始化,修饰实例变量则需要直接初始化或者在构造函数中初始化。修饰方法则方法不能被子类重写。修饰类则该类不能被继承。ps:final修饰的变量还有保证变量可见性的作用。
- finally:用于异常处理,finally结构中的代码无论异常是否发生一定会被执行,一般用于关闭连接资源。
- finalize:是Object类中的方法,在可达性计算后对象不可达,则对象会被加入F-Queue中准备调用一次finalize方法(此前未调用过且finalize方法被重写过),来执行对象回收前的必要清理工作或进行自救。
- static:修饰字段和方法分别表示是类变量和类方法,存放在元空间中,是线程共享的。static还可以用于静态代码块,仅在类加载的时候执行其中的代码一次,可以用于类的初始化。static还可以用于静态内部类,外部可以不创建外部类的实例就可访问静态内部类中的字段和方法。
HashMap内部原理
此问题资料太多了,可以自行查阅并总结。
- 集合遍历删除、添加多个元素的情况下:若不注意往往容易出现问题,出现 ConcurrentModificationException(并发异常,快速失败)。
- 而线程安全的集合遍历采用的是CopyOnWrite的策略,并发下的数据增删操作和遍历的不是同一个集合,因此是并发安全的(安全失败)。
Java IO和NIO的区别
NIO 与普通 I/O 的区别主要有以下两点:
-
NIO是非阻塞式IO,IO是阻塞式IO
操作系统中介绍过,read()系统调用分为两个阶段 等待数据准备 和 将数据从内核拷贝到进程中
Java IO的各种流是阻塞的。这意味着,当一个线程进行read()或write()系统调用时,该线程两个阶段都被阻塞,直到数据从内核缓冲区中拷贝到进程中;而非阻塞式IO,在发送IO相关系统调用后第一阶段是非阻塞的可以去做自己事情,但需要不断轮询内核IO是否完成。
-
NIO面向数据块,IO面向数据流
IO以流的方式处理数据,每次从流中读取一个或者多个字节,数据不进行缓存。而NIO通过数据缓冲区,以数据块的形式进行读取。
NIO 实现了 IO 多路复用中的 Reactor 模型,即一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,线程就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。由于创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好的性能。
具体实现负责select/epoll查询调用的线程,需要不断的进行select/epoll轮询,查找出可以进行IO操作的连接。在select/epoll调用返回前,调用的用户进程是阻塞的。在select/epoll调用返回后,Selector可以获得对应到达的事件,并根据事件类型创建对应通道或者让对应通道读取/写入对应通道缓冲区的数据。
Java NIO在linux系统上,使用的是epoll系统调用。
二、JVM重点知识汇总
JVM运行时数据区
Java内存结构描述的是Java程序执行过程中, 由JVM管理的不同的数据区域。包括以下5部分: 堆内存(heap)、方法区(元空间)、程序计数器、栈内存(stack)、本地方法栈(java中JNI调用)。其中堆和方法区是线程共享的,其他都是线程隔离的。
- 堆内存(线程共享):JVM所管理的内存中最大一块。唯一目的就是存放实例对象和数组对象,几乎所有的对象实例都在这里分配。Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。异常状态 OutOfMemoryError
- 方法区(线程共享):方法区是被所有线程共享的区域。用于存放类的所有信息(字段、方法、构造函数等)、静态变量、常量等数据,还包含运行时常量池(存放编译器生成的各种字面量和符号引用)。异常状态 OutOfMemoryError
- 虚拟机栈(线程私有):一个线程对应一个栈,生命周期与线程相同。描述的是java方法执行的内存模型(每个方法执行时会创建一个栈帧,用于存放局部变量、操作数栈、方法出口等信息)。每一个方法从调用直至完成的过程,对应着一个栈帧在虚拟机中入栈到出栈的过程。异常状态 OutOfMemoryError(栈可以动态扩展的情况下无法申请更多的空间)、StackOverflowError(线程请求栈深度大于虚拟机所允许的深度)。
- 本地方法栈(线程私有):与虚拟机栈作用相似,区别在于本地方法栈用于支持Native方法执行, 存储了每个Native方法调用的状态。
- 程序计数器(线程私有):可看做当前线程所执行的字节码的行号指示器。指向下一个执行字节码位置,并由执行引擎读取并执行指令。
Java中的常量池,实际上分为两种形态:静态常量池和运行时常量池。 所谓静态常量池,即*.class文件中的常量池,这种常量池主要用于存放两大类常量:字面量(Literal)和 符号引用量(Symbolic References) 字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等。 符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
(1)类和接口的全限定名
(2)字段名称和描述符
(3)方法名称和描述符
而运行时常量池,则是JVM虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,并在运行期间动态变化。我们常说的常量池(intern),就是指方法区中的运行时常量池。
如何判断对象是否需要被回收?
-
引用计数法
假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A 的引用计数器-1,如果对象A的计数器的值为0,说明A没有引用,可以被回收。但无法解决循环引用问题(A对象中引用了B,B对象中引用了A,即使A和B都置null,对象内部的引用还存在)。
-
可达性分析法
程序把所有的引用关系看作一张图,从一系列 GC Roots开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,可达的对象都是存活的。当所有的引用节点寻找完毕之后,未在引用链上的对象则被认为是没有被引用到的节点,即无用节点(GC Root 不可达对象),无用节点将会被判定为是可回收的对象。
GC Roots有:虚拟机栈中引用的对象(栈帧中的本地变量表)、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(Native方法)引用的对象。
-
不可达就一定回收?finalize对象回收前最后的挣扎
通过一次可达性分析的不可达对象也是非要被回收不可的,真正宣告一个对象的死亡需要经历至少两次的标记过程。第一次可达性分析被标记为不可达的对象,会进行一次筛选,筛选条件是对象是否重写了finalize()或者finalize()是否已经被调用过一次了。如果对象重写了finalize方法并且finalize方法尚未被调用,则对象被放入一个F-Queue中通过虚拟机Finalizer线程执行每个对象的finalize方法,如果对象通过finalize()方法使自己被引用,则对象在第二次标记时可以移出回收集合,否则就真的被回收了。
Java的四种引用方式和用法
-
强引用
强引用关联的对象不会被垃圾回收器回收。使用new一个新对象的方式来创建强引用。Object obj = new Object();
-
软引用
用来描述一些还有用但并非必须的对象。被软引用关联的对象只有在内存不够的情况下才会被回收,在将要OOM时,先将软引用对象回收,此时内存还不够才OOM。使用 SoftReference 类来创建软引用。Object obj = new Object(); SoftReference<Object> sf = new SoftReference<Object>(obj); obj = null; // 使对象只被软引用关联
-
弱引用
用来描述无用对象的,被弱引用关联的对象只要被垃圾回收器扫描到,无论内存是否足够,就一定会回收,即被弱引用关联的对象只能生存到下一次垃圾收集发生之前。使用 WeakReference 类来创建弱引用。Object obj = new Object(); WeakReference<Object> wf = new WeakReference<Object>(obj); obj = null;
-
虚引用
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的唯一作用是能在这个对象被收集器回收时收到一个系统通知。使用 PhantomReference 来创建虚引用。Object obj = new Object(); PhantomReference<Object> pf = new PhantomReference<Object>(obj, null); obj = null;
垃圾回收算法与其各自的特点
在JVM的实现中,一般会把堆分为新生代和老年代,根据各个两个年代中对象的特征来使用合适的垃圾回收算法(分代回收理论)。比如,新生代的对象照生夕灭,只要复制少量存活对象就能完成垃圾回收,适用标记-复制算法;在老年代,对象存活几率较大,可以选择标记-清除算法或标记-整理算法。
-
标记-复制算法(适用于新生代):
强行在新生代划分边界,一般会划分为Eden区和两个Survivor区(8:1:1),将标记后存活的对象从Eden和一块Survivor区拷贝到另一块Survivor区,然后清空Eden和对应的Survivor区,最后两个Survivor区互换(即保证一个Survivor区总是空的)。
新生代中对象年龄大于动态调整的阈值后会晋升到老年代。其中如果Survivor区大小无法容纳存活的对象,也会通过担保策略将对象直接放入老年代中(新放入的对象还是放入Eden)。
-
标记-清除算法(适用于老年代):
通过可达性分析标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。通过清除算法清理出来的内存,碎片化较为严重。可能会产生新对象没有足够连续空间存放而产生一次Full GC导致程序长时间停顿。(内存空间分配基于空闲列表)
-
标记-整理算法(适用于老年代):
对象的标记跟标记-清除算法相同,但后续是将未被标记的对象(存活对象)移动到内存的一端,然后将另一端的对象全部清除。(内存空间分配基于指针碰撞)
比较标记-清除和标记-整理算法:
- 标记-清除算法只需将标记的对象清除,所以响应速度快。但带来的内存碎片,使得内存分配管理更加复杂,同时碎片达到一定的程度会触发Full GC。
- 标记-整理算法在回收对象的同时将存活对象进行了整理,单次GC由于对象拷贝响应速度会较慢。但由于内存的规整而不需要过多的维护措施,整体的吞吐量更好。
GC类型、触发时机和对象晋升时机
针对HotSpot的实现,GC按精确分类只有两大类:
-
Partial GC(非整堆GC)
按照不同的垃圾回收器,Partial GC又可分为Minor GC(只对新生代回收)、Major GC(只对老年代回收,只有CMS有这种GC)、Mixed GC(G1收集器回收整个新生代和部分老年代)
-
Full GC(整堆+方法区回收)
那么对象何时会从新生代晋升到老年代呢?
-
对象优先在Eden区分配,空间不足存放存活对象则担保分配到老年代
对象优先在Eden区分配,当Eden区空间不足时,将进行一次Minor GC。此时,所有存活对象无法放入Survivor To区时,存活对象直接被担保分配到老年代存储。
首先要判断Minor GC是否是安全的:如果新生代对象的总大小,小于老年代剩余连续空间或者老年代连续空间大于历次晋升的平均大小则触发Minor GC,否则发起Full GC。
新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。 -
大对象直接放入老年代存储(数组、字符串等)
-
年龄阈值晋升
新生代中的对象每经过一次GC其年龄计数就会+1,当超过年龄阈值则晋升到老年代。阈值是动态调整的,Survivor中存活的对象年龄超过某个年龄占一半以上则此年龄为年龄阈值。
常见垃圾回收器
-
Serial 垃圾收集器(分为Serial和Serial Old,分别用于新生代和老年代)
串行垃圾收集器,是指使用单线程进行垃圾回收。垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为Stop-The-World,对于交互性交强的应用而言,这种垃圾收集器是不能够接受的。在G1的FULL GC采用Serial GC进行回收,JVM运行在client模式下也是单线程垃圾回收。
在程序运行参数中(VM options)添加
-XX:+UseSerialGC
设置年轻代和老年代都使用串行垃圾收集器 -
ParNew + CMS(响应优先的垃圾回收器组合)
-
ParNew
新生代垃圾回收器,基于标记-复制算法,将串行的垃圾收集器改为了多垃圾回收线程并行。
注意区分并发和并行,并行是多核CPU一起处理任务,并发是CPU时分复用进行切换。
本文中并行收集意味着是暂停用户线程进行的垃圾回收线程并行执行,而CMS则是并发执行,意味着它的部分阶段是垃圾回收线程和用户线程并发执行的。 -
CMS
老年代垃圾回收器,基于标记-清除算法,部分阶段是用户线程和垃圾回收线程并发执行的,是一种以最短垃圾回收停顿时间为目标的回收器。适合用在对响应要求高的应用场景中。CMS的四个阶段为:
- 初始标记:会导致stw,标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记:垃圾回收线程于用户线程并发执行,GC Roots继续向下标记,但用户线程继续的执行会导致引用域的更改。
- 重新标记:stop the world,多个垃圾回收线程修正并发标记期间用户线程产生的标记变更。
- 并发清除:开启用户线程与GC线程清理标记的对象并发执行。
CMS的优缺点比较明显:优点在于并发执行,用户线程响应速度快。缺点则在于标记-清除算法产生内存碎片,内存管理复杂且容易导致Full GC;其次,并发清除阶段与用户并发执行,产生浮动垃圾只能在下一次GC时清理(也因此CMS有独特的Major GC机制);最后,GC线程长时间与用户线程并发执行,用户程序吞吐量降低。
-
-
Parallel Scavenge+ Parallel Old(吞吐量优先的垃圾回收器组合)
-
Parallel Scavenge
新生代垃圾回收器,基于标记-复制算法,也是多垃圾回收线程并行进行垃圾回收。与ParNew不同的是,这个垃圾回收器更加注重于用户线程的吞吐量(运行用户代码时间 / CPU总消耗时间),可以通过参数设置来调整用户程序的吞吐量。
高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
-
Parallel Old
老年代垃圾回收器,基于标记-整理算法,也是多垃圾回收线程并行进行垃圾回收。与Parallel Scavenge组合实现用户程序的吞吐量和CPU资源配合。
-
-
G1(HBase、Flink的垃圾回收器都优先选择这个,与CMS相比适用于超大堆)
比其他收集器而言,最大的区别在于G1垃圾收集器取消了年轻代、老年代的物理划分,取而代之将堆划分为若干个等大区域(Region,1M~32M),这些区域包含了有逻辑上的年轻代、老年代区域。 每个区域被标记了Eden、Survivor、Old和Humongous(极大,对象超过Region一半),在运行时充当相应的角色。 每个Regin都有一个RememberSet,用来记录该Regin对象的引用对象所在Regin,在做可达性分析时可以避免全堆扫描。
G1中的三种垃圾回收模式:-
Monir GC:
发生在年轻代的GC算法,一般对象(除了巨型对象)都是在Eden Region中分配内存,当所有 Eden Region被耗尽无法申请内存时,就会触发一次Minor GC,采用复制算法,执行完一次Minor GC,存活对象会被拷贝到Survivor Region或者晋升到Old Region中。 -
Mixed GC :
当越来越多地对象晋升到老年代Old Regin时候,为了避免堆内存被耗尽,虚拟机会触发一次混合垃圾回收(Mixed GC)。该算法除了回收整个Young Regin,还会回收一部分Old Regin(维护一个优先队列,优先选择回收价值大的Region)。(阈值可由参数设置)Mixed GC过程如下:- 初始标记:STW,标记GC Roots直接关联对象
- 并发标记:与应用程序并发执行,在整个堆中从GC Roots向下标记
- 最终标记:STW,修正在并发标记期间因用户程序继续运作而导致的标记变动
- 筛选回收:STW,采用复制算法进行垃圾回收,将一部分Regin里的存活对象复制到另一部分Regin中。
-
Full GC :如果对象内存分配速度过快,Mixed GC来不及回收,导致老年代被填满,就会触发一次Full GC,G1的Full GC算法就是单线程执行的Serial GC,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免Full GC的产生。
-
JVM调优与GC优化
JVM类加载机制
- class文件
Java的编译器在编译Java类文件时,会将原有的文本文件(.java)翻译成二进制的字节码,并将这些字节码存储在.class文件中。也就是说java类文件中的属性、方法,以及类中的常量信息,都会被分别存储在.class文件中。当然还会在堆中添加一个公有的静态常量属性.class,这个属性记录了类的相关信息,即类型信息,是Class类的一个实例。
class文件存在的意义就是:跨平台。各种不同平台的虚拟机都统一使用这种相同的程序存储格式。不同平台的JVM可运行相同的.class文件。
-
类加载机制
类加载即虚拟机将class文件加载到内存中的过程,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
正是由于类加载是在运行期间进行的,Java具有天生可以动态扩展的语言特性,提高了程序的灵活性。
-
类加载过程
类加载可分为加载、连接(验证、准备、解析)、初始化。
-
加载(类加载器完成)
通过类型的全限定名,获取代表该类型的二进制字节流;将这个字节流所代表的静态存储结构转换为方法区内的运行时数据结构;在堆内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
-
验证
验证二进制字节流中的信息符合虚拟机要求,不会危害虚拟机自身安全。(包含文件格式验证、元数据验证、字节码验证、符号引用验证)
-
准备
正式为类变量(static)分配内存并设置类变量的零值,这些变量都在方法区(元空间)进行分配。
注意准备阶段为类变量赋每种变量的初始值(零值)而不是我们程序中设置的初始化值。而final常量在这个阶段直接被赋予初始化值。
-
解析
虚拟机将常量池中的符号引用替换为直接引用(指针、句柄)的过程。解析又分为动态解析和静态解析,静态解析在编译时期就完成解析;动态解析则在运行对应指令时触发解析动作(方法调用的动态分派和静态分派,动态分派在invokevirtual指令时动态确定实际执行类型,重写的本质)。
-
初始化
执行构造器<clinit>()方法的过程,该方法将类变量赋值并执行静态代码块,子类的构造器方法调用会优先执行其父类的构造器方法。
-
JVM类加载器与双亲委派模型
- 类加载器
-
启动类加载器(Bootstrap ClassLoader)
由C++实现(针对HotSpot),负责将存放在\\lib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中,即负责加载Java的核心类。
-
扩展类加载器(Extension ClassLoader)
负责加载\\lib\\ext目录或java.ext.dirs系统变量指定的路径中的所有类库,即负责加载Java扩展的核心类之外的类。
-
应用程序类加载器(Application ClassLoader)
负责加载用户类路径(classpath)上的指定类库, 我们可以直接使用这个类加载器,通过ClassLoader.getSystemClassLoader()方法直接获取。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
-
双亲委派模型
类加载器的结构中,要求除了启动类加载器外,其余的类加载器都应该有自己的父类加载器。但这种父子关系是逻辑上的关系,是通过组合的方式进行调用的。
双亲委派模型的工作过程:
如果一个类加载器收到了类加载的请求,首先判断当前类是否被加载过,已经被加载过的类会直接返回。否则,会先把这个请求委派给父加载器去完成,如果父加载器为null则交给启动类加载器加载(所以所有的加载请求最终都应该传送到顶层的启动类加载器中),只有当父加载器反馈自己无法完成加载请求后,子加载器才会尝试自己去加载。
双亲委派模型的原因:避免类的重复加载(同一个类可以被不同类加载器或相同类型的不同加载器对象多次加载),并且保证支持Java的基本类都由启动类加载器加载,而不会出现不同类加载器加载多个相同基本类的情况。
问题:如果自己写一个java.lang.String类能被加载吗?
虽然双亲委派模型是可以被打破的,可以自己写非java.*开头的String/Object类放在用户目录下加载。但用户自己编写一个java.lang.String/Object类并放入程序中,虽能正常编译,但不会被加载运行,因为JVM实现中保证java.*开头的类必须由启动类加载器进行加载。
对象的创建过程(参考JavaGuide)
对象的构成可以看Java对象头部与synchronized原理与优化
- 类加载检查:创建对象时,虚拟机收到一条new指令,首先根据指令参数检查是否能在常量池中定位到类的符号引用,并检查这个符号引用对应的类是否被加载、连接、初始化过,没有则进行类的加载过程。
- 分配内存:在类加载检查后,虚拟机为新生对象分配堆内存空间(大小在类加载后便可以确定)。分配方式有指针碰撞(内存规整,基于标记-整理、标记-复制)和空闲列表(内存不规整,基于标记-清除)。
内存分配的并发问题:在并发内存分配中,一般对象先进入Eden区,首先在Eden区查看是否有自己的TLAB(Thread Local Allocation Buffer),这是一块分配给单个线程存放对象的区域,以线程专有来避免并发造成的对象分配问题。如果,TLAB空间不足且无法再申请,则采用CAS+失败重试的方式来保证对象分配更新的原子性。
- 初始化零值:对实例对象中的实例字段初始化零值。
- 设置对象头(MarkWord):初始化零值后,虚拟机对对象头部值进行设置,比如类型指针、hashcode、GC分代年龄、是否偏向锁等信息。
- 执行<init>方法:在new指令后,执行<init>方法,将对象按照程序意愿进行初始化。
三、Java并发重点知识汇总
线程的状态与转换
- 新建(New):创建后尚未启动。
- 可运行(Runnable):可能正在运行,也可能正在等待 CPU 执行时间片。包含了操作系统线程状态中的 Running 和 Ready。
- 阻塞(Blocked):等待获取一个排它锁,如果其他线程释放锁就会结束此状态。
- 无限期等待(Waiting):等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
- 限期等待(Timed Waiting):无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
- 死亡(Terminated):可以是线程结束任务之后自己结束,或者产生了异常而结束。
几个需要额外注意的内容:
- 阻塞和等待的区别:阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入。
- 调用start()和run()的区别:Thread.start()创建了新的线程,在新的线程中执行其中的run()方法;Thread.run()在主线程中串行执行该方法,和调用普通方法一样。
- 创建线程的三个方法:(1)实现Runnable接口,重写run(),并通过Tread调用start()来启动线程。(2)实现Callable接口,与Runnable相比可以有返回值,返回值通过 FutureTask 进行封装。(3)继承Thread类,除非是一些线程类(Reader、Listener等)否则不建议这么做,Java是单继承多实现,可开启线程执行应当是对此类的一种规范,所以实现接口更加合适。
Java内存模型和Happen-Before原则
-
Java内存模型(JMM)
CPU运行时优化(高速缓存、指令重排、内存屏障等)请看此文~
并发程序存在的问题是,并发情况下共享资源的访问的一致性和安全性问题(由缓存和指令重排序造成)。由此,JMM的关键技术点都是围绕多线程的原子性、可见性和有序性来建立的。
- 原子性:一组操作是不可中断的。
- 可见性:当一个线程修改了某一个共享变量的值时,其他线程能够立即知道这个修改
- 有序性:指令重排序的存在使得重排序后与原指令顺序不同(当然在单线程下串行语义一致,但并发情况下就会有问题)
-
Happen-Before原则
Happen-Before原则定义了JMM中天然的先行发生关系,即哪些指令不能重排。主要有八条:
- 程序次序规则:一个线程内保证语义的串行性
- 锁规则:同一个锁的unlock发生在luck操作前
- volatile规则:对一个volatile变量的写操作发生在其他别的操作之前
- 传递性规则:A操作happen-beforeB操作,B操作happen-beforeC操作,必有A操作happen-beforeC操作。
- 线程启动规则:线程的start()方法先于线程中的其他方法
- 线程中断规则:线程的中断先于中断线程的代码
- 线程终止规则:线程中的所有操作都先于对此线程的终止检测(Thread.join)
- 对象终结规则:一个对象的初始化先于其finalize()的调用
volatile和synchronized的区别
- volatile关键字通过在对修饰的变量进行修改前后加入内存屏障的形式,使得对应变量能够保证可见性和有序性。
- synchronized关键字则通过管程,使得多线程对临界区的访问串性化来保证变量的原子性、可见性、有序性(其实有序性也是能保证的,管程内外的指令禁止重排,内部重排是遵循串行一致性的)。
- volatile只能修饰变量(注意,修饰数组只能保证数组引用的可见性,而对数组中的元素是无法起到保护作用的,从编译中可以看出),而synchronized用在同步代码块和方法。
synchronized关键字
synchronized 规定了同一个时刻只允许一个线程可以进入临界区,由此保证了临界区中共享资源的原子性、可见性和有序性。synchronized保证被它修饰的方法或者代码块的多线程串行执行。
synchronized实现的本质是通过对对象的监视器(monitor)的获取:
任意一个对象都拥有自己的监视器(这也就是为什么wait、notify、notifyAll方法是Object的方法的原因),当同步代码块或方法时,执行方法的线程必须先获得该对象的监视器才能进入同步块或同步方法;没有获取监视器的将会被阻塞,并进入同步队列,状态变为 BLOCKED。当获取监视器的线程释放锁后,才会唤醒阻塞在同步队列中的线程,使其尝试对监视器进行获取。
-
synchronized的用法:
- 修饰普通方法:锁是当前实例的实例对象。
- 修饰静态方法:锁是当前类的class对象。
- 同步代码块:锁是自己设定的一个对象(一般要选择互斥量)。
写个线程安全的单例:
public class Singleton //私有、可见、类变量 private volatile static Singleton instance; private Singleton//不允许外部用构造方法构造对象 public static Singleton getInstance() //单例对象未实例化才进入 if(instance == null) synchronized(Singleton.class) //可能多个线程通过了第一个if判断正在阻塞等待锁 if(instance == null) instance = new Singleton(); return instance;
其中需要注意的点是:
- 单例对象是volatile的,这里是利用了volatile的禁止指令重排序。因为new一个对象在指令中并非原子操作,分为对象空间分配、对象初始化、引用指向。如果不加volatile,在单线程中指令重排可能会先让引用指向对象地址,再初始化对象,而并发场景下可能导致获得的对象未初始化的情况。
- 双重if判断是否已经实例化对象。这是因为并发场景下可能多个线程通过第一个if判断而阻塞等待锁,不加第二个if判断则会创建多个对象。
-
synchronized的底层原理:
-
同步原理:
可以通过javap命令查看同步代码块和同步方法的字节码信息。实质都是对对象监视器的获取。
可以发现,同步代码块实际上是通过
monitorenter指令
和monitorexit指令
来标识同步代码块的开始与结束的。再monitorenter时,线程试图获取对象中的ObjectMonitor,且锁的计数器加1,在monitorexit时,将计数器减1,如果计数器为0了则表明锁被释放了。而同步方法则是在方法上加了ACC_SYNCHRONIZED标识,而实质也是对对象监视器Monitor的获取。
-
synchronized优化
主要可以看Java对象头部与synchronized优化一文
对文中的简单总结是:
synchronized在JDK1.6后就进行优化,不是粗暴地使用
monitorenter
来进行同步。而是加入了偏向锁、轻量级锁、重量级锁来进行优化。- 偏向锁:JVM默认是开启偏向锁的,偏向锁本质就是无锁。在没有多线程对对象进行争抢的情况下,对象头中的Mark Word处于偏向锁状态,记录对应线程ID。当其他线程要进入同步代码块时,会发现锁对象线程ID与自身不同,则对其进行一次CAS进行修改,这时会有两种情况:(1)修改成功,则说明没有线程争抢,则进入这个同步代码块执行。(2)修改失败,说明发生线程争抢,锁升级为轻量级锁。
- 轻量级锁:各个线程都是通过一定次数的CAS操作来争抢对象锁(修改Mark Word),如果对象被锁定则对象头中会保存持有该对象锁的线程指针(指向对应线程Lock record中的Displaced Mark Word),锁标志位为01。
- 重量级锁:当轻量级锁CAS修改Mark Word失败超过设定的次数,仍未抢到锁,则对象锁升级为重量级锁,线程进入阻塞。每个线程执行monitorenter尝试获取对象锁,直至执行到monitorexit释放monitor(或者wait释放锁,notify唤醒继续争抢锁)。
当然,锁优化还有其他如锁粗化(相邻同步代码块锁粗化成一个同步代码块)、锁消除(官方示例给了StringBuffer在连续append时会做一个锁消除优化)等在jit编译运行时的优化。
-
线程同步的几种方法
- synchronized同步关键字
- JUC并发包中的锁(信号量、CountDownLatch、CyclicBarrier、读写锁等)、并发安全集合(ConcurrentHashMap、ConcurrentSkipListMap)、原子类等。
- 使用TreadLocal管理变量:
通过创建ThreadLocal变量,使得访问这个变量的每个线程都会有这个变量的本地副本,每个线程都可以通过get、set方法获取默认值或者更改当前线程所存副本的值,从而避免线程以上是关于JAVA重点知识汇总(包含Java基础JVMJava并发)的主要内容,如果未能解决你的问题,请参考以下文章