🌟 1.具备扎实的Java基础
熟练掌握集合、Synchronized、ThreadLocal、AQS、线程池、JVM内存模型、类加载机制、双亲委派、垃圾回收算法、垃圾回收器、空间分配担保策略、可达性分析、强软弱虚引用、GC的过程、三色标记、跨代引用、内存泄漏与溢出、有JVM调优经验,如JVM调优目的原则、JVM调优常用的工具、排查步骤、各种GC场景下的优化。
🍊 集合
我想谈谈Java集合框架的根接口,其中包含了Collection和Map两个接口。Collection根接口又包含了List和Set两个子接口。
List接口的特点是元素有序且可重复,其中有三个实现类:ArrayList、Vector和LinkedList。ArrayList的底层是一个数组,线程不安全,查找快,增删慢。当我们使用ArrayList空参构造器创建对象时,底层会创建一个长度为10的数组,当我们向数组中添加第11个元素时,底层会进行扩容,扩容为原来的1.5倍。Vector是比ArrayList慢的古老实现类,其底层同样是一个数组,但线程安全。LinkedList的底层是使用双向链表,增删快但查找慢。
Set接口的特点是无序性和不可重复性,其中有三个实现类:HashSet、LinkedHashSet和TreeSet。HashSet的底层是一个HashMap,线程不安全,可容纳null,不能保证元素排列顺序。当向HashSet添加数据时,首先调用HashCode方法决定数据存放在数组中的位置,若该位置上有其他元素,则以链表的形式将该数据存在该位置上,若该链表长度达到8则将链表换成红黑树,以提高查找效率。LinkedHashSet继承了HashSet,底层实现和HashSet一样,可以按照元素添加的顺序进行遍历。TreeSet底层为红黑树,可以按照指定的元素进行排序。
Map的特点是键值对,其中key是无序、不可重复的,value是无序但可重复的,主要实现类有HashMap、LinkedHashMap、TreeMap和HashTable。HashMap的底层实现是一个数组(数组的类型是一个Node类型,Node中有key和value的属性,根据key的hashCode方法来决定Node存放的位置)+链表+红黑树(JDK1.8),线程不安全,可以存放null。LinkedHashMap继承了HashMap底层实现和HashMap一样,可以按照元素添加的顺序进行遍历,底层维护了一张链表用来记录元素添加的顺序。TreeMap可以对key中的元素按照指定的顺序进行排序。HashTable是线程安全的,不可容纳null,若map中有重复的key,后者的value会覆盖前者的value。
🎉 HashMap底层工作原理
在我的工作中,我经常使用HashMap,因此我对HashMap的底层知识有比较深入的了解。比如,当我们向HashMap中插入一个元素(k1,v1)时,它会先进行hash算法得到一个hash值,然后根据hash值映射到对应的内存地址,以此来获取key所对应的数据。如果该位置没有其它元素,它就会直接放入一个Node类型的数组中。默认情况下,HashMap的初始大小为16,负载因子为0.75。负载因子是一个介于0和1之间的浮点数,它决定了HashMap在扩容之前内部数组的填充度。因此,当元素加到12的时候,底层会进行扩容,扩容为原来的2倍。如果该位置已经有其它元素(k2,v2),那么HashMap会调用k1的equals方法和k2进行比较。如果返回值为true,说明二个元素是一样的,则使用v1替换v2。如果返回值为false,说明二个元素是不一样的,则会用链表的形式将(k1,v1)存放。但是,当链表中的数据较多时,查询的效率会下降。为了解决这个问题,在JDK1.8版本中HashMap进行了升级。当HashMap存储的数据满足链表长度超过8,数组长度大于64时,就会将链表替换成红黑树,以此来提高查找效率。
🎉 HashMap版本问题
我曾经了解到关于jdk1.7的hashmap存在着两个无法忽略的问题,其中第一个是在扩容时需要进行rehash操作,这个过程非常消耗时间和空间;第二个是当并发执行扩容操作时,会出现链表元素倒置的情况,从而导致环形链和数据丢失等问题,这些问题都会导致CPU利用率接近100%。而在JDK1.8中,HashMap的这两个问题得到了优化,首先在元素经过rehash之后,其位置要么是在原位置,要么是在原位置+原数组长度,这并不需要像旧版本的实现那样重新计算hash值,而只需要看看原来的hash值新增的那个bit是1还是0就好了。在数组的长度扩大到原来的2倍、4倍、8倍时,索引也会根据保留的二进制位上新增的1或0进行适当调整。其次,在JDK1.8中,发生哈希碰撞时,插入元素不再采用头插法,而是直接插入链表尾部,从而避免了环形链表的情况。不过在多线程环境下,还是会发生数据覆盖的情况,如果同时有线程A和线程B进行put操作,线程B在执行时已经插入了元素,而此时线程A获取到CPU时间片时会直接覆盖线程B插入的数据,从而导致数据覆盖和线程不安全的情况。
🎉 HashMap并发修改异常
在高并发场景下,使用HashMap可能会出现并发修改异常。这种情况是由于多线程争用修改造成的。当一个线程正在写入时,另一个线程也过来争抢,这就导致了线程写入过程被其他线程打断,从而导致数据不一致。针对这种情况,我了解到有四种解决方案。首先,可以使用HashTable,它是线程安全的,但也有缺点。它把所有相关操作都加上了锁,因此在竞争激烈的并发场景中性能会非常差。其次,可以使用工具类Collections.synchronizedMap(new HashMap<>());将HashMap转化成同步的,但是同样会有性能问题。第三种解决方案是使用写时复制(CopyOnWrite)技术。在往容器中加元素时,不会直接添加到当前容器中,而是先将当前容器的元素复制出来放到一个新的容器中,然后在新的容器中添加元素。写操作完毕后,再将原来容器的引用指向新的容器。这种方法可以进行并发的读,不需要加锁。但是在复制的过程中会占用较多的内存,并且不能保证数据的实时一致性。最后,使用ConcurrentHashMap则是一种比较推荐的解决方案。它使用了volatile,CAS等技术来减少锁竞争对性能的影响,避免了对全局加锁。在JDK1.7版本中,ConcurrentHashMap使用了分段锁技术,将数据分成一段一段的存储,并为每个段配备了锁。这样,当一个线程占用锁访问某一段数据时,其他段的数据也可以被其他线程访问,从而能够实现真正的并发访问。在JDK1.8版本中,ConcurrentHashMap内部使用了volatile来保证并发的可见性,并采用CAS来确保原子性,来解决了性能问题和数据一致性问题。
🎉 HashMap影响HashMap性能的因素
影响HashMap性能的两个关键因素:加载因子和初始容量。加载因子用于确定HashMap<K,V>中存储的数据量,并且默认加载因子为0.75。如果加载因子比较大,扩容发生的频率就会比较低,而浪费的空间会比较小,但是发生hash冲突的几率会比较大。举个例子,如果加载因子为1,HashMap长度为128,实际存储元素的数量在64至128之间,这个时间段发生hash冲突比较多,会影响性能。如果加载因子比较小,扩容发生的频率会比较高,浪费的空间也会比较多,但是发生hash冲突的几率会比较小。比如,如果加载因子为0.5,HashMap长度为128,当数量达到65的时候会触发扩容,扩容后为原理的256,256里面只存储了65个,浪费了。因此,我们可以取一个平均数0.75作为加载因子。另一个影响HashMap性能的关键因素是初始容量,它始终为2的n次方,可以是16、32、64等这样的数字。即使你传递的值是13,数组长度也会变成16,因为它会选择最近的2的n次方的数。在HashMap中,使用(hash值 &(长度-1))的二进制进行&运算来得到元素在数组中的下标。这样做可以保证运算得到的值可以落到数组的每一个下标上,避免了某些下标永远没有元素的情况。
举个例子,如果我有一个HashMap,容量为16,我的hash值是
11001110 11001111 00010011 11110001(hash值)
然后我要进行&运算,运算的值是
00000000 00000000 00000000 00001111(16-1的2进制)
这个值是16-1的2进制表示。然后,我就进行&运算了,得到的结果是
00000000 00000000 00000000 00000001
这个运算的意思是,我把hash值的2进制的后4位和1111进行比较,然后,我的hash值的后4位的范围是0000-1111之间,这样我就可以与上1111,最后的值就可以在0000-1111之间,也就是0-15之间。这样可以保证运算后的值可以落到数组的每一个下标中。如果数组长度不是2的幂次,后四位就不可能是1111,这样如果我用0000~1111的一个数和有可能不是1111的数进行&运算,那么就有可能导致数组的某些位下标永远不会有值,这样就无法保证运算后的值可以落在数组的每个下标上面。
🎉 HashMap使用优化
对于HashMap的使用优化,我个人有五点看法。首先,我建议使用短String、Integer这些类作为键,特别是String,因为它是不可变的,final的,已经重写了equals和hashCode方法,符合HashMap计算hashCode的不可变性要求,可以最大限度地减少碰撞的出现。其次,我建议不要使用for循环遍历Map,而是使用迭代器遍历entrySet,因为在各个数量级别迭代器遍历效率都比较高。第三,建议使用线程安全的ConcurrentHashMap来删除Map中的元素,或者在迭代器Iterator遍历时,使用迭代器iterator.remove()方法来删除元素。不可以使用for循环遍历删除,否则会产生并发修改异常CME。第四,建议在设定初始大小时要考虑加载因子的存在,最好估算存储的大小。可以使用Maps.newHashMapWithExpectedSize(预期大小)
来创建一个HashMap,Guava会帮我们完成计算过程,同时考虑设定初始加载因子。最后,如果Map是长期存在而key又是无法预估的,那就可以适当加大初始大小,同时减少加载因子,降低冲突的机率。在长期存在的Map中,降低冲突概率和减少比较的次数更加重要。
🍊 Synchronized
Synchronized关键字在Java语言中是用来保证同一时刻只有一个线程执行被Synchronized修饰的代码块或方法。如果Synchronized修饰的是方法或对象,则该对象锁是非静态的,如果修饰的是静态方法或类,则该类锁是静态的,所有的该类对象共用一个锁。每个Java对象都有一把看不见的锁,也称为内部锁或Monitor锁。Synchronized的实现方式是基于进入和退出Monitor对象来实现方法和代码块同步。每个Java对象都是天生的Monitor,Monitor监视器对象存在于每个Java对象的对象头MarkWord里面,也就是存储指针的指向,Synchronized锁通过这种方式获取锁。
在JDK6之前,Synchronized加锁是通过对象内部的监视器锁来实现的,这种监视器锁的本质是依赖于底层的操作系统的Mutex Lock来实现。由于操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要比较长的时间。
JDK6版本及以后,Sun程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,大多数对象的加锁和解锁都是在特定的线程中完成,出现线程竞争锁的情况概率比较低,比例非常高,所以引入了偏向锁和轻量级锁。
从无锁到偏向锁的转换是一个多步骤的过程。第一步是检测MarkWord是否为可偏向状态,如果是偏向锁则为1,锁标识位为01。第二步是测试线程ID是否为当前线程ID,如果是,则直接执行同步代码块。如果不是,则进行CAS操作竞争锁,如果竞争成功,则将MarkWord的线程ID替换为当前线程ID。如果竞争失败,就启动偏向锁撤销并让线程在全局安全点阻塞,然后遍历线程栈查看是否有锁记录,如果有,则需要修复锁记录和MarkWord,让其变成无锁状态。最后恢复线程并将偏向锁状态改为0,偏向锁升级为轻量级锁。
对于轻量级锁升级,首先在栈帧中建立锁记录,存储锁对象目前的MarkWord的拷贝。这是为了在申请对象锁时可以以该值作为CAS的比较条件,并在升级为重量级锁时判定该锁是否被其他线程申请过。成功拷贝后,使用CAS操作将对象头MarkWord替换为指向锁记录的指针,并将锁记录空间里的owner指针指向加锁的对象。如果更新成功,当前线程则拥有该对象的锁,对象MarkWord的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。如果更新操作失败,虚拟机将检查对象MarkWord中的Lock Word是否指向当前线程的栈帧,如果是,则当前线程已经拥有该对象的锁,直接进入同步块继续执行。如果不是,说明多个线程竞争锁,进入自旋。如果自旋失败,轻量级锁将转换为重量级锁,锁标志的状态值变为“10”,MarkWord中存储的是指向重量级锁的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。最后,如果新线程过来竞争锁,锁将升级为重量级锁。
当一个线程需要获取某个锁时,如果该锁已经被其他线程占用,我们可以使用自旋锁来避免线程阻塞或者睡眠。自旋锁是一种策略,它不能替代阻塞,但是它可以避免线程切换带来的开销。使用自旋锁,线程会一直循环检测锁是否被释放,直到获取到锁。但是使用自旋锁也有一些坏处,频繁的自旋操作会占用CPU处理器的时间,因此自旋锁适用于锁保护的临界区很小的情况,如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。但是自旋的次数必须要有一个限度,如果自旋超过了限度仍然没有获取到锁,就应该被挂起。由于程序锁的状况是不可预估的,JDK1.6引入了自适应的自旋锁,以根据不同的程序锁状态自适应地调整自旋的次数,提高自旋的效率并减少CPU的资源浪费。为了开启自旋锁,我们可以使用参数–XX:+UseSpinning。并且可以使用–XX:PreBlockSpin来修改自旋次数,默认值是10次。
当一个线程在等锁时,它会不停地自旋。事实上,底层就是一个while循环。当自旋的线程达到CPU核数的1/2时,就会升级为重量级锁。这时,锁标志被置为10,MarkWord中的指针指向重量级的monitor,所有没有获取到锁的线程都会被阻塞。Synchronized实际上是通过对象内部的监视器锁(Monitor)来实现的。这个监视器锁本质上是依赖于底层的操作系统的MutexLock来实现的。操作系统实现线程之间的切换需要从用户态转换到核心态,状态之间的转换需要比较长的时间。这就是为什么Synchronized效率低的原因。我们称这种依赖于操作系统MutexLock所实现的锁为“重量级锁”。重量级锁撤销之后是无锁状态。撤销锁之后会清除创建的monitor对象并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。GC清除掉monitor对象之后,就会撤销为无锁状态。
🍊 ThreadLocal
ThreadLocal是Java中的一个类,它可以实现线程间的数据隔离。这意味着每个线程都可以在自己的ThreadLocal对象内保存数据,从而避免了多个线程之间对数据的共享。相比之下,Synchronized则用于线程间的数据共享,它通过锁的机制来确保在某一时间点只有一个线程能够访问共享的数据。ThreadLocal的底层实现方式是在Thread类中嵌入了一个ThreadLocalMap。在这个ThreadLocalMap中,每个ThreadLocal对象都有一个threadLocalHashCode。这个threadLocalHashCode是用来在ThreadLocalMap中定位到对应的位置的。当数据存储时,ThreadLocalMap会根据threadLocalHashCode找到对应的位置,并在该位置上存储一个Entry对象。这个Entry对象中,key为ThreadLocal对象,value则为对应的数据。在获取数据时,同样会根据threadLocalHashCode找到对应的位置,然后判断该位置上的Entry对象中的key是否与ThreadLocal对象相同。如果相同,则返回对应的value。这种方式可以保证每个线程都可以拥有自己的数据副本,从而实现线程间的数据隔离。在实际应用中,ThreadLocal经常被用来保存一些线程相关的信息,例如用户信息、语言环境等。这样可以让每个线程都能独立地处理自己的相关信息,而不会受到其他线程的影响。
🍊 AQS
AQS——它的全称是AbstractQueuedSynchronizer,中文意思是抽象队列同步器,它是在java.util.concurrent.locks包下,也就是JUC并发包。在Java中,我们有synchronized关键字内置锁和显示锁,而大部分的显示锁都用到了AQS。例如,只有一个线程能执行ReentrantLock独占锁,又比如多个线程可以同时执行共享锁Semaphore、CountDownLatch、ReadWriteLock、CyclicBarrier。AQS自身没有实现任何同步接口,仅仅是定义了同步状态获取和释放的方法,并提供自定义同步组件使用。子类通过继承AQS,实现该同步器的抽象方法来管理同步状态。使用模板方法模式,在自定义同步组件里调用它的模板方法。这些模板方法会调用使用者重写的方法,这是模板方法模式的一个经典运用。AQS依赖于内部的一个FIFO双向同步队列来完成同步状态的管理。如果当前线程获取同步状态失败,同步器会将当前线程信息构造为一个节点,并将其加入同步队列,同时会阻塞当前线程。当同步状态释放时,首节点中的线程将会被唤醒,使其再次尝试获取同步状态。同步器拥有首节点和尾节点,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。没有成功获取同步状态的线程会成为节点,加入该队列的尾部。
让我们以ReentrantLock为例,线程调用ReentrantLock的lock()方法进行加锁。这个过程中会使用CAS将state值从0变为1。一旦线程加锁成功,就可以设置当前加锁线程是自己。ReentrantLock通过多次执行lock()加锁和unlock()释放锁,对一个锁加多次,从而实现可重入锁。当state=1时代表当前对象锁已经被占用,其他线程来加锁时则会失败。再看加锁线程的变量里面是否为自己。如果不是就说明有其他线程占用了这个锁,失败的线程被放入一个等待队列中,并等待唤醒的时候,经常会使用自旋的方式,不停地尝试获取锁,等待已经获得锁的线程释放锁才能被唤醒。当它释放锁的时候,将AQS内的state变量的值减1,如果state值为0,就彻底释放锁,会将“加锁线程”变量设置为null。这时,会从等待队列的队头唤醒其他线程重新尝试加锁,获得锁成功之后,会把“加锁线程”设置为线程自己,同时线程自己就从等待队列出队。
底层实现独占锁的代码中,首先会调用自定义同步器实现的tryAcquire方法,保证线程安全的获取同步状态。如果获取成功,则直接退出返回;如果获取失败,则构造同步节点,通过addWaiter方法将该节点加入到同步队列的尾部。最后调用acquireQueued方法,让节点自旋获取同步状态。在Java 5之前,如果一个线程在synchronized之外获取不到锁而被阻塞,即使对该线程进行中断操作,中断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。而在Java 5中,等待获取同步状态时,如果当前线程被中断,会立即返回,并抛出InterruptedException。后续的版本又提供了超时获取同步状态的方法,支持响应中断,也是获取同步状态的“增强版”。其中,doAcquireNanos方法在支持响应中断的基础上,增加了超时获取的特性。
对于超时获取,需要计算出需要睡眠的时间间隔nanosTimeout。为了防止过早通知,nanosTimeout的计算公式为:nanosTimeout = now - lastTime
,其中now为当前唤醒时间,lastTime为上次唤醒时间。如果nanosTimeout大于0,表示超时时间未到,需要继续睡眠nanosTimeout纳秒;否则,表示已经超时。如果nanosTimeout小于等于1000纳秒时,将不会使该线程进行超时等待,而是进入快速的自旋过程。这是因为非常短的超时等待无法做到十分精确,如果此时再进行超时等待,反而会让nanosTimeout的超时从整体上表现得不精确。因此,在超时非常短的场景下,同步器会无条件进入快速自旋。
共享锁是一种同步机制,不同于独占锁,可以允许多个线程同时访问临界区。举个例子,如果我们需要5个子线程并行执行一个任务,可以使用CountDownLatch来实现。我们初始化一个state为5的CountDownLatch,每个子线程执行完任务后调用countDown()方法,state就会减1。当state变为0时,主调用线程从await()函数返回,继续后续动作。在调用同步器的acquireShared方法时,通过tryAcquireShared方法来判断是否能够获取到同步状态。如果可以,就可以进入临界区。需要保证tryReleaseShared方法能够安全释放同步状态。通常会使用循环和CAS来保证线程安全。因为同一时间可以有多个线程获取到同步状态,所以需要使用双向链表来记录等待线程。双向链表有两个指针,可以支持O(1)时间复杂度的前驱结点查找,插入和删除操作也更高效。此外,为了避免链表中存在异常线程导致无法唤醒后续线程的问题,阻塞等待的前提是当前线程所在节点的前置节点是正常状态。如果被中断的线程的状态被修改为CANCELLED,需要从链表中移除,否则会导致锁唤醒的操作和遍历操作之间的竞争。如果使用单向链表,实现起来会非常复杂。加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是否是头节点,如果不是,就不需要竞争锁。
🍊 线程池
线程池,简单来说就是对运行线程数量的控制,它通过将任务放到队列中来进行处理,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,那么就会排队等候,等待其他线程先执行完毕,再从队列中取出任务去执行。就像银行网点一样,线程池中的常驻核心数相当于今日当值窗口,线程池能够同时执行的最大线程数相当于银行所有的窗口,任务队列相当于银行的候客区。当同时需要执行的任务数量超过了最大线程数,线程池会将多余的任务放到等待区(相当于候客区),当等待区满的时候,就会按照一定的策略进行拒绝。
当底层创建线程池的时候,有七个核心参数,分别是:核心线程数、同时执行的最大线程数、多余线程存活时间、单位时间秒、任务队列、默认线程工厂以及拒绝策略。其中,最大线程数就是指同时能够执行的最大线程数量,多余线程存活时间指的是当前线程池数量超过核心线程数时,当前空闲时间达到多余线程存活时间的值的时候,多余空闲线程会被销毁到只剩核心线程数为止。任务队列则是被提交但尚未被执行的任务。同时,为了应对不同的需求,线程工厂可以为不同类型的线程提供不同的创建方式。拒绝策略则是用来保证性能和稳定性,当队列满了并且工作线程数量大于线程池的最大线程数时,提供拒绝策略,以便及时应对各种意外情况。
针对CPU密集型任务的特性,我们需要考虑线程池中核心线程数量的设定,如果线程池中核心线程数量过多,会增加上下文切换的次数,带来额外的开销。因此我们需要确保有足够的线程数量去处理任务,以充分利用CPU运算能力,而不浪费CPU时间在上下文切换上。一般情况下,我们建议线程池的核心线程数量等于CPU核心数+1。对于I/O密集型任务,由于CPU使用率并不是很高,可以让CPU在等待I/O操作的时去处理别的任务,从而充分利用CPU。因此线程池中的核心线程数量也需要根据任务类型来进行设定。一般情况下,建议线程的核心线程数等于2*CPU核心数。对于混合型任务,我们需要根据任务类型和线程等待时间与CPU时间的比例来设定线程池的核心线程数量。在某些特定的情况下,还可以将任务分为I/O密集型任务和CPU密集型任务,分别让不同的线程池去处理。一般情况下,线程池的核心线程数应该等于(线程等待时间/线程CPU时间+1)*CPU核心数。打个比方,就像我们写作业或者工作时,需要根据任务类型和资源利用率来设定工作方式,我们需要在不同的任务之间切换来达到更高的效率。如果我们一味地等待一个任务完成,而不去做其他的任务,那么效率就会非常低下。因此线程池的设计也需要根据任务类型和特性来进行规划和优化。
在讨论拒绝策略时,有几种不同的策略可以选择。首先,第一种拒绝策略是AbortPolicy。当线程池中的线程数达到最大值时,系统将直接抛出一个RejectedExecutionException异常,从而阻止系统的正常运行。通过感知到任务被拒绝,我们可以根据业务逻辑选择重试或者放弃提交等策略。第二种拒绝策略,该策略不会抛弃任务,也不会抛出异常。相反,它会将某些任务回退给调用者。当线程池无法处理当前任务时,将执行任务的责任交还给提交任务的线程。这样,提交的任务不会丢失,从而避免了业务损失。如果任务耗时较长,提交任务的线程在此期间也会处于忙碌状态,无法继续提交任务。这相当于一个负反馈,有助于线程池中的线程消化任务。第三种拒绝策略是DiscardOldestPolicy。当任务提交时,如果线程池中的线程数已经达到最大值,它将丢弃队列中等待最久的任务,并将当前任务加入队列中尝试再次提交。第四种拒绝策略是DiscardPolicy。与前三种策略不同,DiscardPolicy直接丢弃任务,不对其进行处理,也不会抛出异常。当任务提交时,它直接将刚提交的任务丢弃,而且不会给出任何提示通知。总的来说,这四种拒绝策略各有优缺点,具体选择哪种策略取决于实际业务需求和场景。
在Java中,java.util.concurrent包提供的Executors来创建线程池。它提供了三种常用的线程池类型:第一种是newSingleThreadExecutors,它是单线程线程池,适用于只有一个任务的场景。第二种是newFixedThreadPool(int nThreads),它是固定大小线程池,适用于任务数已知的场景。第三种是newCachedThreadPool(),它是无界线程池,适用于任务数不确定的场景,但是这种线程池的队列相当于没有限制,可能会出现OOM的问题。我建议在实际应用中不要使用JDK提供的三种常见创建方式,因为这些方式使用场景很有限,而且底层都是通过ThreadPoolExecutor创建的线程池。相比之下,直接使用ThreadPoolExecutor创建线程池更容易理解原理,也更加灵活。此外,阿里巴巴开发手册也推荐使用ThreadPoolExecutor去创建线程池,因为它可以灵活地控制任务队列的大小,避免了OOM等问题的出现。
🍊 JVM内存模型
在JDK1中,JVM只有堆内存和方法区两个部分。其中,堆内存负责存储对象实例,方法区则负责存储类信息、常量池、方法描述等。在JDK1中,没有虚拟机栈、本地方法栈和程序计数器等部分,因此对于异常处理和线程同步等方面,只能通过操作系统提供的方式实现。
在JDK2中,JVM新增了虚拟机栈和程序计数器两个部分。虚拟机栈用于存储每个线程的方法调用栈,程序计数器则记录每个线程当前执行的字节码指令位置。在JDK2中,还没有本地方法栈。
在JDK3中,JVM新增了本地方法栈。本地方法栈和虚拟机栈类似,只不过它是为本地方法服务的,用于支持JVM调用本地方法的机制。JDK3的内存模型中,JVM共有堆内存、方法区、虚拟机栈、本地方法栈和程序计数器五个部分。
在JDK4中,JVM对内存模型进行了大幅度优化。其中,JVM实现了分代垃圾回收,即将堆内存分为新生代和老年代两部分。新生代中又分为Eden区和两个Survivor区。在JDK4中,方法区仍然存在,但用了称为"永久代"的概念。它用于存储类信息、方法描述、常量池等数据,并将它们缓存起来,以便在JVM运行时进行访问。
在JDK5中,JVM对内存模型进行了一些小改进。其中,引入了泛型和自动装箱/拆箱等新特性,这些特性需要JVM在处理对象时进行额外的内存操作。为此,JVM引入了TLAB(线程本地分配缓冲区)机制,用于加速对象的分配过程。
在JDK6中,JVM对内存模型进行了一些优化和改进。其中,引入了"永久代"的概念,来替代原有的方法区。永久代可以动态调整大小,以适应JVM的内存需求。此外,JVM还优化了GC算法,加快了垃圾回收的速度。
在JDK7中,JVM主要修改了内存分配器和垃圾回收器。其中,引入了G1(Garbage First)垃圾回收器,用于处理大内存和高并发的场景。G1垃圾回收器将堆内存分为若干个区域,每个区域都可以独立进行垃圾回收。
在JDK8中,JVM主要改进了垃圾回收器。其中,改进了永久代的存储结构,将永久代替换成了元空间,使得元空间可以根据需要动态地调整大小。此外,JVM还引入了新的垃圾回收器,如CMS(Concurrent Mark-Sweep)和ZGC(Z Garbage Collector),用于提高JVM的性能和稳定性。
在JDK11中,JVM进一步优化了内存分配器和垃圾回收器。其中,引入了Epsilon垃圾回收器,该回收器不对内存进行垃圾回收,而是保留所有对象,直到内存用尽为止。另外,JVM还引入了ZGC的并发模式,提升了JVM在高并发场景下的性能表现。
在JDK17中,JVM主要优化了元空间的性能和稳定性。特别是针对大型应用程序,元空间的性能得到了显著提升。此外,JVM还引入了新的垃圾回收器,如Flight Recorder和Shenandoah,用于提升JVM的性能和稳定性。
🍊 类加载机制与双亲委派
首先,当我们编译Java源文件后,就会生成一个class字节码文件存储在磁盘上。接着,JVM会读取这个字节码文件,使用IO流进行读取,这个过程就是加载。加载是由类加载器完成的,它会检查当前类是不是由自定义加载类加载的,如果不是,就委派应用类加载器加载。如果这个类已经被加载过了,就不需要再次加载。如果没有被加载过,就会委派父加载器调用loadClass方法来加载。如果父加载器加载不了,就会一直向上查询,直到启动类加载器。如果所有的加载器都不能加载这个类,就会抛出ClassNotFoundException异常,这就是所谓的双亲委派机制。这种机制可以避免同路径下同文件名的类的冲突。比如,自己写了一个java.lang.obejct,这个类和jdk里面的object路径相同,文件名也一样,这个时候,如果不使用双亲委派机制的话,就会出现不知道使用哪个类的情况,而使用了双亲委派机制,它就委派给父类加载器就找这个文件是不是被加载过,从而避免了上面这种情况的发生。
接下来是验证阶段。JVM会校验加载进来的字节码文件是不是符合JVM规范。首先,会进行文件格式验证,即验证class文件里的魔数和主次版本号,发现它是一个jvm可以支持的class文件并且它的主次版本号符合兼容性要求,所以验证通过。如果符合要求,就进行元数据验证,对字节码描述的信息进行语义分析,比如判断是否有父类、是否实现了父类的抽象方法、是否重写了父类的final方法等。然后是字节码验证,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。最后是符号引用验证,确保解析动作可以正确执行,比如能否找到对应的类和方法,以及符号引用中类、属性、方法的访问性是否能被当前类访问等。
在完成验证后,我们进入了准备阶段,这时需要为类的静态变量分配内存并赋予默认值。比如说,如果我们有一个public static int a = 12;的变量,我们需要给它分配默认值0。同理,对于一个public static User user = new User();的变量,我们需要为静态变量User分配内存并赋予默认值null。但如果这个变量是用final修饰的常量,那么就不需要再分配默认值,直接赋值就可以了。接下来是解析,就是将符号引用变为直接引用。这个过程会将静态方法替换为指向数据储存在内存中的指针或者句柄,也就是所谓的直接引用。这个过程是在初始化之前完成的。最后是初始化阶段,类的静态变量被初始化为指定的值,并且会执行静态代码块。比如说,在准备阶段,我们的public static final int a = 12;变量会被赋上默认值0,而在初始化阶段,我们需要把它赋值为12。同样地,我们的public static User user = new User();这个变量需要在初始化阶段进行实例化。
最后,就是使用和卸载阶段。至此,整个加载流程就走完了。
🍊 垃圾回收算法、垃圾回收器、空间分配担保策略
垃圾回收器有很多,其中新生代的有三种,分别是Serial、ParNew和Parallel Scavenge。Serial采用的是复制算法,是单线程运行的,没有线程交互开销,专注于垃圾回收。但是由于会冻结所有应用线程,且只能在单核cpu下工作,因此一般不使用。ParNew也是采用复制算法,但是支持多线程并行gc,相比Serial,除了多核cpu并行gc以外,其他基本相同。Parallel Scavenge也是采用复制算法,但是它能够进行吞吐量控制的多线程回收,主要关注吞吐量,可以通过设置吞吐量来控制停顿时间,适用于不同的场景。
新生代的垃圾回收器都使用复制算法进行gc。按照分代收集算法的思想,堆空间被分为年轻代、老年代和永久代。其中年轻代又被分为Eden区和两个Survivor存活区,比例为8:1:1。进行gc时,对象会先被分配在Eden区,然后进行minor gc。在新生代中,每次gc都需要回收大部分对象,因此为了避免内存碎片化的缺陷,采用复制算法按内存容量将内存划分为大小相等的两块,每次只使用其中一块,在minor gc期间,存活的对象会被复制到其中一个Survivor区,Eden区继续放对象,直到触发gc。此时,Eden区和存放对象的Survivor区一起gc,存活下来的对象会被复制到另一个空的Survivor区,两个Survivor区角色互换。
进入老年代的几种情况,首先是当对象在Survivor区躲过一次GC后,年龄就会加1,存活的对象在两个Survivor区不停的移动,默认情况下,年龄到达15的对象会被移到老生代中,这是对象进入老年代的第一种情况。
第二种情况是创建了一个很大的对象,这个对象的大小超过了JVM里面的一个参数max tenuring thread hold值,这个时候不会创建在Eden区,新对象直接进入老年代。
第三种情况是如果在Survivor区里面,同一年龄的所有对象大小的总和大于Survivor区大小的一半,年龄大于等于这个年龄对象的就可以直接进入老年代。举个例子,存活区只能容纳5个对象,有五个对象,1岁、2岁、2岁、2岁、3岁,3个2岁的对象占了存活区空间的5分之三,大于这个空间的一半了,这个时候大于等于2岁的对象需要移动到老年代里面,也就是3个2岁的和一个3岁的对象移动到老年代里面。
还有第四种情况,Eden区存活的对象超过了存活区的大小,会直接进入老年代里面。另外,在发生minor GC之前,必须检查老年代最大可用连续空间是否大于新生代所有对象的总空间,如果大于,这一次的minor GC可以确保是安全的,如果不成立,JVM会检查自己的handlepromotionfailure这个值是true还是false。True表示运行担保失败,False则表示不允许担保失败。如果允许,就会检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,如果大于就尝试一次有风险的minor GC,如果小于或者不允许担保失败,那就直接进行full GC了。
举个例子,在minor GC发生之前,年轻代里面有1GB的对象,这个时候,老年代瑟瑟发抖,JVM为了安慰这个老年代,它在minor GC之前,检查一下老年代最大可用连续空间,假设老年代最大可用连续空间是2GB,JVM就会拍拍老年代的肩膀说,放心,哪怕年轻代里面这1GB的对象全部给你,你也吃得下,你的空间非常充足,这个时候,老年代就放心了。但是大部分情况下,在minor GC发生之前,JVM检查完老年代最大可用连续空间以后,发现只有500MB,这个时候虚拟机不会直接告诉老年代你的空间不够,这个时候会进行第二次检查,检查自己的一个参数handlepromotionfailure的值是不是允许担保失败,如果允许担保失败,就进行第三次检查。检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,假设历次晋升到老年代平均对象大小是300MB,现在老年代最大可用连续空间只有500MB,很明显是大于的,那么它会进行一次有风险的minor GC,如果GC之后还是大于500MB,那么就会引发full GC了,但是根据以往的一些经验,问题不大,这就是允许担保失败。假设历次晋升到老年代平均对象大小是700MB,现在老年代最大可用连续空间只有500MB,很明显是小于的,minor GC风险太大,这个时候就直接进行full GC了,这就是我们所说的空间分配担保。
老年代使用的垃圾回收器有Serial Old和Parallel Old,采用的是标记整理算法。
标记整理算法是标记后将存活对象移向内存的一端,然后清除端边界外的对象。标记整理算法可以弥补标记清除算法当中,内存碎片的缺点,也消除了复制算法当中,内存使用率只有90%的现象,不过也有缺点,就是效率也不高,它不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记整理算法要低于复制算法。
Serial Old是单线程运行的垃圾回收器,而Parallel Old是可以进行吞吐量控制的多线程回收器,在JDK1.6开始提供,可以保证新生代的吞吐量优先,无法保证整体的吞吐量。
CMS是老年代使用标记清除算法,标记清除算法分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。CMS是并发收集低停顿的多线程垃圾回收器。它使用的是4个阶段的工作机制,分别是初始标记、并发标记、重新标记和并发清除。并发标记和并发清除过程中,垃圾收集线程可以和用户线程一起并发工作,因此CMS收集器的内存回收和用户线程可以一起并发地执行,但它无法处理浮动垃圾,容易产生大量的内存碎片。
G1收集器将堆内存划分为若干个独立区域,每个区域分为Eden区、Survivor区和大对象区。采用的是标记整理算法,能够非常精确地控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。它能避免全区域垃圾收集,保证在有限时间内获得最高的垃圾收集效率。在jdk1.9中,G1成为默认的垃圾回收器。