jvm 类加载机制原理?
一、JVM 类加载机制
JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化,
\1. 加载:
加载是类加载过程中的第一个阶段,这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个 Class 文件获取,这里既
可以从 ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类)。
\2. 验证:
这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
\3. 准备:
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:
public` `static` `int` `v = ``8080``;
实际上变量 v 在准备阶段过后的初始值为 0 而不是 8080,将 v 赋值为 8080 的 put static 指令是程序被编译后,存放于类构造器方法之中。
但是注意如果声明为:
public` `static` `final` `int` `v = ``8080``;
在编译阶段会为 v 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 v赋值为 8080。
\4. 解析:
解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是 class 文件中的:
\1. CONSTANT_Class_info
\2. CONSTANT_Field_info
\3. CONSTANT_Method_info
等类型的常量。
符号引用:符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
直接引用:直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
\5. 初始化:
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。
初始化阶段是执行类构造器方法的过程。方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子方法执行之前,父类的方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成()方法。
注意以下几种情况不会执行类初始化:
1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
2. 定义对象数组,不会触发该类的初始化。
3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
4. 通过类名获取 Class 对象,不会触发类的初始化。
5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。
二、类加载器
\1. 启动类加载器(Bootstrap ClassLoader)
负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类。
\2. 扩展类加载器(Extension ClassLoader)
负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库。
\3. 应用程序类加载器(Application ClassLoader):
负责加载用户路径(classpath)上的类库。 JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader实现自定义的类加载器。
三、双亲委派
当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。
垃圾收集算法有哪里?
说到Java,一定绕不开GC,尽管不是Java首创的,但Java一定是使用GC的代表。GC就是垃圾回收,更直接点说就是内存回收。是对内存进行整理,从而使内存的使用尽可能大的被复用。 一直想好好写一篇关于GC的文章,可是却发现要写的东西太大了,不是一篇博客能简单的介绍完的。所以打算拆分成若干篇博客,一点点的总结下来。 本篇主要介绍的是GC中的常用算法。这些算法被广泛的应用于各个内存管理语言的虚拟机中,或者是各大常用的操作系统中。 说到GC,也就是垃圾回收,那么需要做的事有两件:
第一件是查找到哪些内存中的对象是已经废弃掉的。
第二件事是如何清理掉这些已经废弃掉的数据。
本文先来说说后者,如何清除掉这些内存中的废弃数据。
1、标记清除算法Mark-Sweep
这种算法是最简单最直接的算法,也是其它算法的一些最初思路。标记清除算法其实就是对内存中的对象依次的进行判断,如果对象需要回收那么就打一个标记,如果对象仍然需要使用,那么就保留下来。这样经过一次迭代之后,所有的对象都会被筛选判断一次。紧接着会对内存中已经标记的对象依次进行清除。 这个算法比较简单粗暴,实现起来比较简单。
但是会留下两个比较麻烦的问题:
(1)标记和清除需要两遍循环内存中的对象,标记本身也是一个比较麻烦的工作,因此这种算法的效率不是特别的高。
(2)对于分配的内存来说,往往是连续的比较好,因为这样有利于分配大数据的对象。倘若当前内存中都是小段的内存碎片,会知道需要分配大段内存时,没有可以放置的位置,而触发内存回收。也就是空间不足而导致频繁GC和性能下降。
2、复制算法Copying
在使用数据库的过程中,曾经遇到这样一个问题,表中的数据量相对来说比较大,大概30万行,需要使用多个苛刻的条件删除其中的大部分数据(因此无法使用索引),而只保留其中的较少数据。常规的delete语法使用起来是超时的。于是我查看维护人员的sql,发现一个很有意思的逻辑。首先查出数据库表中需要保留的数据,放到一张临时表中。然后彻底删除掉原有的数据表。然后把这张临时创建表的表名,改为当初的表名。这是一种典型的空间换取时间的方法。而复制算法就是这样一个思路。
复制算法中,会将内存划分为两块相等大小的内存区域A/B,然后生成的数据会存放在A区,当A区剩余空间不足以存放下一个新创建的对象时,系统就会将A区中的有效对象全部复制到B区中,而且是连续存放的。然后直接清空A区中的所有对象。 由于编程语言中的对象,大部分在创建后很快就会被回收掉,所以我们需要复制的对象其实并不多。 Java中的实现是这样的: Java中将Eden和Survivor区同时作为复制算法的使用区域。Survivor又分为From区和To区。每次GC的时候都会将Eden和Survivor的From区中的有效对象进行标记,一同复制到Survivor的To区。然后彻底清除原来的Eden区和From区的内存对象。与此同时To区就是下一次回收的From区。
复制算法的缺点: 算法使用了空间换取时间的思路,因此需要一块空白的区域作为内存对象要粘贴的区域。这无疑会造成一种浪费。尤其是内存较小时。 算法每次清除无效对象时,都要进行一次复制粘贴的对象转移,因此对使用场景是有限制的。只有在有效对象占据总回收内存是非常小的时候,这种算法的性价比才会达到最高。否则大量的复制动作所浪费的时间可能要远远大于空间换取时间得到的收益。因此这种算法在Jvm中,也只被用来作为初级的对象回收。因为这时的有效对象比例最低,算法的性价比是最高的。
3、 标记整理算法 Mark-Compact
复制算法需要一块额外的内存空间,用于存放幸存的内存对象。这无疑造成了内存的浪费。我们还可以在原有的标记清除算法的基础上,提出了优化方案。也就是标记到的可用对象整体向一侧移动,然后直接清除掉可用对象边界意外的内存。这样既解决了内存碎片的问题。又不需要原有的空间换时间的硬件浪费。由于老年代中的幸存对象较多,而且对象内存占用较大。
这就使得一旦出现内存回收,需要被回收的对象并不多,碎片也就相对的比较少。所以不需要太多的复制和移动步骤。因此这种方法常常被应用到老年代中。
标记整理算法的缺点: 标记整理算法由于需要不断的移动对象到另外一侧,而这种不断的移动其实是非常不适合杂而多的小内存对象的。每次的移动和计算都是非常复杂的过程。因此在使用场景上,就注定限制了标记整理算法的使用不太适合频繁创建和回收对象的内存中。
4、分代收集算法 Generational Collection
这种算法就是将内存以代的形式划分,然后针对情况分别使用性价比最高的算法进行处理。在Java中,一般将堆分为老年代和新生代。新创建的对象往往被放置在新生代中。而经过不断的回收,逐渐存活下来的对象被安置到了老年代中
java中的并发容器有哪些?
不考虑多线程并发的情况下,容器类一般使用 ArrayList、HashMap 等线程不安全的类,效率更高。在并发场景下,常会用到 ConcurrentHashMap、ArrayBlockingQueue 等线程安全的容器类,虽然牺牲了一些效率,但却得到了安全。
上面提到的线程安全容器都在 java.util.concurrent 包下,这个包下并发容器不少,今天全部翻出来鼓捣一下。
1.ConcurrentHashMap 并发版 HashMap
最常见的并发容器之一,可以用作并发场景下的缓存。底层依然是哈希表,但在 JAVA 8 中有了不小的改变,而 JAVA 7 和 JAVA 8 都是用的比较多的版本,因此经常会将这两个版本的实现方式做一些比较(比如面试中)。
一个比较大的差异就是,JAVA 7 中采用分段锁来减少锁的竞争,JAVA 8 中放弃了分段锁,采用 CAS(一种乐观锁),同时为了防止哈希冲突严重时退化成链表(冲突时会在该位置生成一个链表,哈希值相同的对象就链在一起),会在链表长度达到阈值(8)后转换成红黑树(比起链表,树的查询效率更稳定)。
2.CopyOnWriteArrayList 并发版 ArrayList
并发版 ArrayList,底层结构也是数组,和 ArrayList 不同之处在于:当新增和删除元素时会创建一个新的数组,在新的数组中增加或者排除指定对象,最后用新增数组替换原来的数组。
适用场景:由于读操作不加锁,写(增、删、改)操作加锁,因此适用于读多写少的场景。
局限:由于读的时候不会加锁(读的效率高,就和普通 ArrayList 一样),读取的当前副本,因此可能读取到脏数据。如果介意,建议不用。
看看源码感受下:
3.CopyOnWriteArraySet 并发 Set
基于 CopyOnWriteArrayList 实现(内含一个 CopyOnWriteArrayList 成员变量),也就是说底层是一个数组,意味着每次 add 都要遍历整个集合才能知道是否存在,不存在时需要插入(加锁)。
适用场景:在 CopyOnWriteArrayList 适用场景下加一个,集合别太大(全部遍历伤不起)。
4.ConcurrentLinkedQueue 并发队列 (基于链表)
基于链表实现的并发队列,使用乐观锁 (CAS) 保证线程安全。因为数据结构是链表,所以理论上是没有队列大小限制的,也就是说添加数据一定能成功。
5.ConcurrentLinkedDeque 并发队列 (基于双向链表)
基于双向链表实现的并发队列,可以分别对头尾进行操作,因此除了先进先出 (FIFO),也可以先进后出(FILO),当然先进后出的话应该叫它栈了。
6.ConcurrentSkipListMap 基于跳表的并发 Map
SkipList 即跳表,跳表是一种空间换时间的数据结构,通过冗余数据,将链表一层一层索引,达到类似二分查找的效果
7.ConcurrentSkipListSet 基于跳表的并发 Set
类似 HashSet 和 HashMap 的关系,ConcurrentSkipListSet 里面就是一个 ConcurrentSkipListMap,就不细说了。
8.ArrayBlockingQueue 阻塞队列 (基于数组)
基于数组实现的可阻塞队列,构造时必须制定数组大小,往里面放东西时如果数组满了便会阻塞直到有位置(也支持直接返回和超时等待),通过一个锁 ReentrantLock 保证线程安全。
乍一看会有点疑惑,读和写都是同一个锁,那要是空的时候正好一个读线程来了不会一直阻塞吗?
答案就在 notEmpty、notFull 里,这两个出自 lock 的小东西让锁有了类似 synchronized + wait + notify 的功能。传送门 → 终于搞懂了 sleep/wait/notify/notifyAll
9.LinkedBlockingQueue 阻塞队列 (基于链表)
基于链表实现的阻塞队列,想比与不阻塞的 ConcurrentLinkedQueue,它多了一个容量限制,如果不设置默认为 int 最大值。
10.LinkedBlockingDeque 阻塞队列 (基于双向链表)
类似 LinkedBlockingQueue,但提供了双向链表特有的操作。
11.PriorityBlockingQueue 线程安全的优先队列
构造时可以传入一个比较器,可以看做放进去的元素会被排序,然后读取的时候按顺序消费。某些低优先级的元素可能长期无法被消费,因为不断有更高优先级的元素进来。
12.SynchronousQueue 数据同步交换的队列
一个虚假的队列,因为它实际上没有真正用于存储元素的空间,每个插入操作都必须有对应的取出操作,没取出时无法继续放入。
import java.util.concurrent.SynchronousQueue;
public class Main {
public static void main(String[] args) {
SynchronousQueue<Integer> queue = new SynchronousQueue<>();
new Thread(()->{
try{
for(int i=0;;i++){
System.out.println("放入:" + i);
queue.put(i);
}
}catch (InterruptedException e){
e.printStackTrace();
}
}).start();
new Thread(()->{
try{
while(true){
System.out.println("取出:" + queue.take());
Thread.sleep((long)(Math.random()*2000));
}
}catch (InterruptedException e){
e.printStackTrace();
}
}).start();
}
}
运行结果:
取出:0
放入:0
取出:1
放入:1
放入:2
取出:2
取出:3
放入:3
取出:4
放入:4
...
...
可以看到,写入的线程没有任何 sleep,可以说是全力往队列放东西,而读取的线程又很不积极,读一个又 sleep 一会。输出的结果却是读写操作成对出现。
JAVA 中一个使用场景就是 Executors.newCachedThreadPool(),创建一个缓存线程池。
13.LinkedTransferQueue 基于链表的数据交换队列
实现了接口 TransferQueue,通过 transfer 方法放入元素时,如果发现有线程在阻塞在取元素,会直接把这个元素给等待线程。如果没有人等着消费,那么会把这个元素放到队列尾部,并且此方法阻塞直到有人读取这个元素。和 SynchronousQueue 有点像,但比它更强大。
14.DelayQueue 延时队列
可以使放入队列的元素在指定的延时后才被消费者取出,元素需要实现 Delayed 接口。
总结
13.LinkedTransferQueue 基于链表的数据交换队列
实现了接口 TransferQueue,通过 transfer 方法放入元素时,如果发现有线程在阻塞在取元素,会直接把这个元素给等待线程。如果没有人等着消费,那么会把这个元素放到队列尾部,并且此方法阻塞直到有人读取这个元素。和 SynchronousQueue 有点像,但比它更强大。
14.DelayQueue 延时队列
可以使放入队列的元素在指定的延时后才被消费者取出,元素需要实现 Delayed 接口。
总结
上面简单介绍了 JAVA 并发包下的一些容器类,知道有这些东西,遇到合适的场景时就能想起有个现成的东西可以用了。想要知其所以然,后续还得再深入探索一番。