🌟 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插入的数据,从而导致数据覆盖和线程不安全的情况。
篇幅限制下面就只能给大家展示小册部分内容了。整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记及答案【扫一扫】 即可免费获取**
🎉 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,需要从链表中移除,否则会导致锁唤醒的操作和遍历操作之间的竞争。如果使用单向链表,实现起来会非常复杂。加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是否是头节点,如果不是,就不需要竞争锁。
篇幅限制下面就只能给大家展示小册部分内容了。整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记及答案【扫一扫】 即可免费获取**
🍊 线程池
线程池,简单来说就是对运行线程数量的控制,它通过将任务放到队列中来进行处理,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,那么就会排队等候,等待其他线程先执行完毕,再从队列中取出任务去执行。就像银行网点一样,线程池中的常驻核心数相当于今日当值窗口,线程池能够同时执行的最大线程数相当于银行所有的窗口,任务队列相当于银行的候客区。当同时需要执行的任务数量超过了最大线程数,线程池会将多余的任务放到等待区(相当于候客区),当等待区满的时候,就会按照一定的策略进行拒绝。
当底层创建线程池的时候,有七个核心参数,分别是:核心线程数、同时执行的最大线程数、多余线程存活时间、单位时间秒、任务队列、默认线程工厂以及拒绝策略。其中,最大线程数就是指同时能够执行的最大线程数量,多余线程存活时间指的是当前线程池数量超过核心线程数时,当前空闲时间达到多余线程存活时间的值的时候,多余空闲线程会被销毁到只剩核心线程数为止。任务队列则是被提交但尚未被执行的任务。同时,为了应对不同的需求,线程工厂可以为不同类型的线程提供不同的创建方式。拒绝策略则是用来保证性能和稳定性,当队列满了并且工作线程数量大于线程池的最大线程数时,提供拒绝策略,以便及时应对各种意外情况。
针对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成为默认的垃圾回收器。
🍊 引用计数器算法、可达性分析、强软弱虚引用、GC的过程、三色标记、跨代引用
🎉 引用计数器算法、可达性分析
在JVM中,所有的对象都存在一个对象头。对象头包括了对象的类型信息、对象的状态信息和对象的引用信息。在对象的引用信息中,有一个重要的字段是“引用计数器”,它记录了该对象被引用的次数。当该对象被引用时,计数器增加1;当该对象不被引用时,计数器减少1。当计数器的值为0时,该对象就可以被垃圾回收了。
但是,引用计数器算法存在一个问题,就是无法解决循环引用的问题。如果两个对象相互引用,它们的引用计数器的值始终不为0,就无法进行垃圾回收。因此,JVM采用了可达性分析算法。
如果一个对象已经不再被任何其他对象引用,那么该对象就是不可达的,即它不再被程序使用,可以被回收。在 JVM 中,可达性分析是通过根对象来判断对象是否可达的,比如:当前正在执行的方法中的局部变量和输入参数,线程栈中的对象,静态对象等。判断一个对象是否可达,首先从根对象开始对所有引用进行遍历,找到所有被引用的对象。将这些被引用的对象标记为活动对象,其它对象则被标记为垃圾对象。从活动对象开始对所有引用进行遍历,找到所有被引用的对象,将这些被引用的对象标记为活动对象,其它对象则被标记为垃圾对象。这个过程一直进行下去,直到没有对象可遍历,所有被遍历的非垃圾对象都被标记为活动对象,其它对象都被标记为垃圾对象。
JVM 对不可达对象的处理一般是通过垃圾回收机制来完成的。当 JVM 发现某个对象不再被任何根对象引用时,该对象就变成了不可达对象,这个对象会被标记为垃圾对象。垃圾回收器会在 JVM 空闲时根据特定算法对这些垃圾对象进行回收,回收的过程包括两个阶段:标记和清除。标记阶段:从根对象开始向下遍历所有引用,标记所有被引用的对象,其它对象则被标记为垃圾对象。清除阶段:清除所有被标记为垃圾对象的内存空间,回收这些空间。
🎉 强软弱虚引用
JVM中强软弱虚引用是Java中内存管理的重要概念。
- 强引用是最为常见的引用类型,是指存在一个对象的引用,它会防止对象被垃圾回收器回收。即使内存不足时,JVM也不会回收被强引用引用的对象,除非该对象的引用被明确地赋值为null。
Object obj = new Object();
// obj是一个强引用
AI写代码java
运行
12
2. 软引用是比较常用的引用类型之一,它用于描述一些还有用但并非必需的对象,软引用通常用于缓存数据,当内存不足时,JVM可以回收软引用的对象,从而释放缓存空间。当JVM需要内存时,会先回收这些软引用,如果空间仍然不足,才会抛出OOM异常。可以通过SoftReference类来实现软引用。
SoftReference<Object> softRef = new SoftReference<>(new Object());
// softRef是一个软引用
AI写代码java
运行
12
3. 弱引用与软引用类似,它也是用于描述一些还有用但并非必需的对象,但是与软引用不同,弱引用被回收的时机更加快速,我们可以使用弱引用来实现一些临时性的对象,比如缓存中的某些对象,当不再需要这些对象时,JVM会自动回收它们。在垃圾回收时,只要发现存在弱引用引用的对象,就会被回收。可以通过WeakReference类来实现弱引用。
WeakReference<Object> weakRef = new WeakReference<>(new Object());
// weakRef是一个弱引用
AI写代码java
运行
12
4. 虚引用是最为特殊的引用类型,它与前面的三种引用类型不同,虚引用并不会影响对象的生命期,而是用于在对象被回收时收到一个系统通知,可以实现资源的释放,比如文件句柄、网络连接等,如果我们直接使用强引用进行管理,容易出现资源泄露的问题。而使用虚引用则可以避免这个问题,因为虚引用在对象被回收时,会收到一个通知,然后程序可以在收到通知之后及时地释放资源。这样,程序员可以在对象被回收时进行一些清理操作。虚引用必须与ReferenceQueue(虚引用队列)一起使用。
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);
// phantomRef是一个虚引用
AI写代码java
运行
12
🎉 GC的过程
在进行垃圾回收前,GC需要首先找出哪些内存对象是需要被回收的。这个过程称为垃圾标记,通常需要遍历整个堆空间,找出所有还在使用的对象。为了标记一个对象是否为垃圾,GC需要维护一个活动对象集合(Active Set)。一开始,所有对象都被认为是活动对象。然后,从根对象(如程序计数器、虚拟机栈、本地方法栈)开始,GC深度遍历所有可以被访问到的对象。如果一个对象无法被访问到,那么它就被认为是垃圾对象。
标记完垃圾对象后,GC便开始对其进行回收。垃圾回收完毕后,堆中的内存空间可能会变得非常零散。为了避免这种情况,GC会对堆中的对象进行移动和整理,使得所有的存活对象都能够在连续的内存空间中占据位置。这个过程称为内存整理。内存整理的主要工作是将所有存活对象移动到一端,然后清理出空闲的内存块。这个过程会涉及到对象的引用修改,需要将所有指向存活对象的引用进行更新。
当一个对象变成不可达时,它就成为了垃圾,需要被垃圾收集器回收。但是,垃圾收集器不会立即回收这个对象,而是把它放到F-Queue队列中,等待一个低优先级的线程在后台去读取这些不可达的对象。当线程调用这些对象的finalize()方法时,如果这个方法被覆盖过并且被调用过,那么虚拟机将视这个对象为不需要再执行finalize()方法了,否则它会被放回到待回收的集合中,等待下一次垃圾回收。如果在第二次标记时,这个对象还没有被重新关联到引用链上,那么就真的可以被垃圾回收器回收了。所以,finalize()方法实际上是一个对象的最后一次机会去逃脱垃圾回收的命运。
🎉 三色标记
三色标记算法是一种用于垃圾回收的算法,它可以识别并回收不再使用的内存空间,从而避免内存泄漏的问题。该算法实现的核心思想是通过将内存对象标记为三种状态中的一种来实现垃圾回收。三色标记算法将内存对象标记为白色、灰色和黑色三种状态。一开始,所有的对象都是白色的,表示这些对象都是可回收的垃圾。当程序运行时,每次访问一个对象时,该对象的状态会从白色变成灰色;灰色对象表示正在被垃圾回收器扫描的对象。当垃圾回收器遍历某个对象时,该对象被标记为灰色。在遍历完该对象的所有引用之后,该对象就被标记为黑色。如果某个灰色对象引用了某个白色对象,则该白色对象也被标记为灰色;黑色对象表示已经被垃圾回收器扫描到的对象。
通过三色标记算法,可以有效地避免内存泄漏问题,并实现高效的垃圾回收。值得注意的是,该算法需要在程序运行时频繁地标记对象的状态,因此可能会对程序的性能产生一定的影响。在三色标记算法中,如果存在循环引用问题,会导致算法无法正确地标记对象的颜色。例如,如果对象A引用了对象B,而对象B也引用了对象A,则在第一次标记时,A和B都会被标记为灰色,但是在扫描完A后,由于B还未被扫描,因此B的颜色仍然为灰色,而垃圾收集器并不知道这是一个循环引用的问题,因此会将B标记为黑色,从而造成垃圾回收器无法回收B。为了解决JVM三色标记算法中的循环引用问题,可以打破循环引用,常用的方法是使用“延迟引用”。具体来说,当遍历到一个对象的引用时,不立即标记为灰色,而是将它暂时记录下来,等到该对象被标记为黑色时,再将它标记为灰色。这样可以避免循环引用问题,同时也不会增加太多的开销。
JVM三色标记的工作原理可以概括为以下几个步骤:首先,垃圾回收器将所有对象都涂成白色。然后,从根对象开始遍历所有的对象,将所有可达的对象涂成灰色。在遍历过程中,如果发现某个灰色对象引用了某个白色对象,则将该白色对象涂成灰色。当所有可达对象都被涂成灰色后,垃圾回收器将所有黑色对象保留下来,将其余白色对象清除。最后,将所有黑色对象重新涂成白色。
🎉 跨代引用
跨代引用是指在堆内存中,年轻代中的对象被老年代中的对象引用的情况。当进行年轻代的垃圾回收(minor gc)时,需要判断哪些对象还需要保留,哪些对象可以被回收。如果按照常规思路,需要遍历老年代中所有的对象,非常耗费时间和性能。为了优化跨代引用的垃圾回收,JVM引入了一种抽象数据结构——记忆集。记忆集是非收集区域指向收集区域的指针集合,记录了老年代对象引用年轻代对象的指针。在进行年轻代垃圾回收时,只需要遍历记忆集中被标记的指针,就可以确定哪些对象需要保留,哪些对象可以被回收。
跨代引用主要有几种情况:第一种是将对象从年轻代移动到老年代时,需要将指向该对象的引用从年轻代的引用表中复制到老年代的引用表中,以确保对象在移动后仍能够被访问。第二种是在进行Full GC(Full Garbage Collection,即对整个堆空间进行垃圾收集)时,会遍历整个堆空间。如果在堆空间中发现一个对象被另一个对象所引用,且该被引用的对象在老年代中,而引用该对象的对象在年轻代中,就需要进行跨代引用。第三种是在进行压缩垃圾收集时,需要将所有可达对象移动到内存区域的起始位置。如果一个对象在年轻代中,而它所引用的对象在老年代中,就需要进行跨代引用。
记忆集采用了一些优化机制,如卡表和写屏障,避免了全局扫描老年代的低效率问题。卡表是一个大小等于老年代的位图,它将老年代按照固定大小(默认为512B)分成很多个区域,每个区域对应卡表中的一个位。当年轻代中的对象与老年代中的对象建立关联时,虚拟机会将这个老年代区域对应的卡表位标记为“脏”,表明它需要被扫描。这样,GC时只需要扫描所有被标记为“脏”的老年代区域,而不是全局扫描老年代。写屏障也是一种优化机制,它用于捕获在年轻代中产生的对象引用,将其放入到卡表中。当年轻代中的对象被分配内存时,虚拟机会通过写屏障来监视对象的引用情况。如果有一个对象的引用发生了变化,比如一个对象被移动到了另一个区域,虚拟机会通过写屏障将这个对象的新引用信息更新到相应的卡表中,保证卡表的准确性和正确性。这样,JVM在进行垃圾回收时,可以避免不必要的扫描和浪费,提高了垃圾回收的效率和性能。
🍊 内存泄漏与堆积、溢出
内存泄漏是程序在分配内存后,由于设计或编写缺陷无法释放已分配的内存,从而导致系统或进程逐渐耗尽可用的内存空间。一般有三种原因:第一种是变量未销毁,即定义并分配内存的变量在程序运行结束后未被销毁,会导致内存泄漏;第二种是指针未及时释放内存,以指针的形式分配内存后未及时释放会产生内存泄漏;第三种是内存管理错误,通常是程序中使用错误的内存分配和释放方法,例如使用了malloc/new分配内存但未使用free/delete释放内存。
内存泄漏通常会导致程序运行变慢或崩溃,因此可以使用编译器调试工具如Visual Studio等捕获内存泄漏,然后跟踪变量,检查变量是否及时释放,还可以使用内存管理工具如Valgrind检测和调试内存泄漏,最后可以使用智能指针来避免内存泄漏,智能指针可以自动管理内存空间,避免内存泄漏的发生。
内存泄漏会让内存不停地增加,最后会爆满,导致程序崩溃。这种情况通常是由代码导致的。我们可以用visualVM这个工具来进行内存转储,查看哪个类占用了太多的内存空间,然后再检查它所引用的实例和引用。最后,我们可以定位到代码的具体问题。如果我们的堆内存很大,使用visualVM产生的资源成本太高,我们可以尝试使用轻量级的jmap工具来生成堆转储快照进行分析,这种方法与使用visualVM的思路相同。
内存溢出就是当程序试图向内存申请空间时,由于申请的空间太大超出了系统或进程可分配的内存空间,导致程序无法正常运行。内存溢出的原因主要有三种,第一种是申请空间过大,当程序向内存申请过大的空间时,容易导致内存溢出,可以使用分片申请空间的方法来避免。第二种是内存泄漏,即使程序本身没有缺陷,也可能因为内存泄漏导致内存耗尽从而造成内存溢出。第三种是错误的内存管理,例如使用了错误的内存分配和释放方法或指针操作错误等。为了避免内存溢出,可以在程序开始时预留一定的空间,使用内存池提高程序效率,使用Memcheck、Purify等工具进行内存溢出分析报告,改进内存管理方法使用智能指针等方法减少内存泄漏和溢出的问题,采用一些有效的内存优化技术减少内存占用提高程序效率和稳定性。
🍊 JVM调优经验
在JVM中,FGC指的是全垃圾收集,这是一个对整个堆内存进行垃圾回收的过程。然而,它也会让应用程序暂停,并且会影响应用程序的性能,这是我们不想看到的。FGC通常在以下情况下发生:首先是堆内存不足,当堆内存不足时,JVM会启动FGC以释放内存空间。其次是大量对象生成,当应用程序生成大量对象时,堆内存可能会很快被占满,此时JVM会触发FGC。还有一种情况是对象生命周期短,如果应用程序中大量对象的生命周期很短,那么这些对象很快就会成为垃圾,导致JVM启动FGC。为了减少FGC的出现,我们可以采取以下策略。首先,增加堆内存的大小可以减少由于内存不足而导致的FGC。其次,通过对代码进行优化,减少不必要的对象生成,可以减少FGC的发生。此外,我们可以在对象的生命周期结束后尽可能地重用这些对象,避免频繁的对象生成和回收。还有一种方法是使用对象池等技术,这可以减少对象的创建和销毁,从而减少FGC的发生。最后,在程序需要暂停的空闲时间,可以手动触发System.gc()方法,对垃圾进行回收,从而减少FGC的发生。
JVM调优步骤:首先,我们需要收集数据。我们可以使用jstat命令来监视JVM的内存和处理器使用信息,也可以使用jmap命令生成堆转储快照。另外,我们还可以使用GUI工具如JConsole或VisualVM对CPU、内存或堆使用状态进行监视。第二步,我们需要分析数据。通过使用工具分析收集到的数据,我们可以计算GC吞吐量和新生代大小等,也可以查看堆转储信息,分析堆中对象的分布情况,是否有内存泄漏等问题。接下来,第三步,我们需要制定具体的优化方案。我们可以根据分析的数据确定具体的优化方案,比如适当调整内存大小、调整垃圾回收机制、优化代码等。对于GC调优,可以尝试调整GC算法、分配大对象空间、增加GC并行度等。对于内存调优,可以尝试减少对象的创建、复用对象等。第四步,我们需要验证优化效果。我们可以使用性能测试工具如jmeter或ab进行压力测试,以验证优化效果是否符合预期。最后,第五步,我们需要持续监控。在优化后,我们需要持续监控应用程序,及时发现并解决新问题,进行JVM调优。
JVM调优其实十分复杂,针对不同场景的问题,我们可以从以下几个角度进行设计:
首先,如果是大访问压力下,MGC频繁一些是正常的,只要MGC延迟不导致停顿时间太长或者引发FGC,可以适当增大Eden空间大小,降低频繁程度。当然,要注意空间增大对垃圾回收产生的停顿时间增长是否可以接受。
其次,如果是MinorGC频繁且容易引发Full GC,需要分析MGC存活对象的大小,是否能够全部移动到S1区。如果S1区大小小于MGC存活对象大小,这批对象会直接进入老年代。这种情况下,应该在系统压测的情况下,实时监控MGC存活对象的大小,并合理调整Eden和S区的大小以及比例。
第三,如果由于大对象创建频繁导致Full GC频繁,可以通过控制JVM参数来优化对象的大小。如果代码层面无法优化,则需要考虑调高参数的大小,或者定时脚本触发Full GC,尽量保证该对象确实是长时间使用的。
第四,如果MGC和FGC的停顿时间长导致影响用户体验,需要考虑减少堆内存大小,包括新生代和老年代。也要考虑线程是否及时达到了安全点,查看安全点日志并对代码进行针对性调整。
最后,如果出现内存泄漏导致MGC和FGC频繁,就需要对代码进行大范围的调整,例如大循环体中的new对象,未使用合理容器进行对象托管等等。无论如何,JVM调优的目的就是在系统可接受的情况下达到一个合理的MGC和FGC的频率以及可接受的回收时间。
篇幅限制下面就只能给大家展示小册部分内容了。整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记及答案【扫一扫】 即可免费获取**
🌟 2.深入理解MySQL关系型数据库
索引数据结构、脏读、 不可重复读、幻读、隔离级别、原子性底层实现原理(undo log日志 )、 一致性底层实现原理、持久性底层实现原理(redo log机制)、隔离性底层实现原理(MVCC多版本并发控制)、BufferPool缓存机制、行锁、表锁、间隙锁、死锁、主键自增长实现原理、索引失效、聚集索引、辅助索引、覆盖索引、联合索引、SQL的执行流程、有MySQL调优经验,如表结构设计优化、SQL优化、灾备处理、异常发现处理、数据服务、数据分区分库分表、主从复制、读写分离、高可用(双主故障切换、高可用性与可伸缩性、组复制)经验。
🍊 索引数据结构
B树和B+树都是基于平衡多叉树的结构,用于快速查找和排序大量数据。B树的每个节点可以存储关键码和数据,而B+树只在叶子节点中存储数据,非叶子节点仅存储索引信息。B+树相比B树具有更高效的磁盘IO、更适合范围查询和排序以及插入和删除操作更加高效等优势。在查询数据时,B+树的叶子节点包含所有的关键字数据,而非叶子节点仅仅包含索引数据,从而能够更好地适应范围查找和排序操作。
MySQL是从磁盘读取数据到内存的,是以磁盘块为基本单位的,位于同一磁盘块中的数据会被一次性读取出来,不是按需读取。InnoDB存储引擎使用页作为数据读取单位,页面是其磁盘管理的最小单位,一页的大小默认为16kb。系统的一个磁盘块的存储空间往往没有这么大,所以InnoDB每次申请磁盘空间时都会是多个地址连续磁盘块来达到页的大小16KB。在查询数据时,一个页中的每条数据都能定位数据记录的位置,这会减少磁盘I/O的次数,提高查询效率。InnoDB存储引擎在设计时是将根节点常驻内存的,力求达到树的深度不超过3,也就是说I/O不超过3次。
结合B树和B+树的特点以及对磁盘的分析,我们可以看出,B+树更适合大量数据的储存和查询。B+树的叶子节点之间通过指针串联,形成一个有序链表,因此在进行区间查询时只需要遍历叶子节点即可,数据访问效率更高。B+树的非叶子节点数目比B树的节点数目大得多,因为B+树的非叶子节点只存储关键码,因此可以显得更矮胖。B+树相比于B树,高度更低,因而访问更快。通过对数据库索引结构和磁盘基础设施的了解,我们可以更好地理解和优化数据库查询性能。
🍊 隔离级别、脏读、 不可重复读、幻读、幻影行
在数据库中隔离级别是多个事务之间可以看到对方对数据的更改情况。比如,一个事务在修改数据时,另一个事务能不能够看到数据在修改,这些修改能不能可以取消。目前常见的隔离级别有四种:读未提交、读已提交、可重复读和串行化。
举个例子,假设有两个人Tom和Jerry同时向银行存款,Tom存了100元,Jerry存了200元。
如果他们的事务隔离级别为读未提交,那么在Tom存款未提交之前,Jerry就可以看到Tom的存款已经生效了。但如果Tom的存款被回滚,Jerry之前看到的数据就是脏数据。读未提交隔离级别是最低的隔离级别,它允许一个事务读取另一个事务未提交的数据。这可能会导致脏读的情况,也就是读取到了未提交的数据,如果数据回滚,读取的数据将变得无效。
如果隔离级别为读已提交,那么只有在Tom的存款事务提交后,Jerry才能看到已经生效,这意味着读已提交隔离级别会引入小幅的延迟,因为Jerry必须等待Tom的事务提交才能看到结果。读已提交隔离级别要求一个事务只能读取另一个已经提交了的数据,这样就避免了脏读出现的情况。但它可能会导致不可重复读的问题,也就是在同一事务内,同样的查询条件下多次查询同一数据,但是得到的结果不同。这是因为另一个事务在该事务两次查询之间修改了数据。
如果隔离级别为可重复读,那么Jerry可以在Tom的事务提交前多次查询,因此数据的一致性得到更好的保障,但是会消耗更多的系统资源来维护一致性。可重复读隔离级别要求一个事务在执行过程中多次查看同样的数据,它能够保证在一个事务内多次查询同一数据时得到的结果是一致的。但它可能会导致幻读的问题,也就是在同一事务内,同样的查询条件下多次查询数据,但是得到的结果不同,这与不可重复读的区别在于幻读是由于另一个事务插入了新数据导致的,而不是修改数据。
如果隔离级别为串行化,那么Tom和Jerry的存款事务必须一个一个地执行,不能同时进行,这意味着一个事务必须在另一个事务完成之后才能执行,这将会带来更高的延迟和更大的系统资源开销。串行化隔离级别是最高的隔离级别,它要求所有的事务串行执行,避免了并发访问产生的所有问题。但它会导致更高的延迟和更大的系统资源开销。
MySQL默认的隔离级别是可重复读,这是因为MySQL认为可重复读是一个良好的默认隔离级别,可以提供足够的隔离性和性能。在可重复读隔离级别下,每个事务读取的数据都是一致的,即使其他事务对数据进行了修改,它们的修改也不会影响到当前事务的读取结果。另外,可重复读隔离级别也可以提供足够的性能。因为它不会对读取数据加锁,而是使用多版本并发控制(MVCC)机制来实现隔离性。这可以避免了对数据的过度访问和锁竞争,从而提高了并发性能。
可重复读可以避免脏读和不可重复读的问题,但存在幻读问题,并且在MySQL 5.7版本中将其作为一个已知的问题公开了。在MySQL 8.0版本中引入了一种新的隔离级别——可重复读快照隔离级别,它可以解决幻读问题,同时保持了可重复读级别的并发性能。它是在可重复读隔离级别的基础上做的优化。
可重复读快照隔离级别的实现方式是在事务开始时,创建一个事务快照,这个快照包含了所有在事务开始之前已提交的数据。在事务执行过程中,读取的都是这个快照中的数据,而不是直接读取数据库中的数据。事务执行过程中,其他事务对数据的修改不会影响到正在执行的事务。这样的话,对于同一个事务,在可重复读隔离级别下,多次读取同一数据时,得到的结果都是一样的。可重复读快照隔离级别与可重复读隔离级别最大的区别在于当有新的事务加入时,可重复读隔离级别下的事务会重新建立快照,而在可重复读快照隔离级别中,事务快照只会在事务开始时被建立,因此这个隔离级别的并发性能更好。
只不过可重复读快照隔离级别不是绝对安全的,因为在事务执行过程中,如果有其他事务对数据进行了删除操作,那么当前事务在读取数据时可能会出现“幻影行”的情况。在数据库中,幻影行指的是一个事务在执行查询操作时,可能会发现一些之前不存在的行或者少了一些行,这些行就像幻影一样突然出现或消失了。可重复读快照隔离级别只能保证读取到的数据与事务开始时相同,但它并不能防止其他并发事务在事务执行过程中更新或插入数据。所以,当一个事务在读取数据时,如果同时有其他事务在对数据进行增删改操作,就可能会出现幻影行的情况。
为了解决这个问题,需要使用行级锁或使用串行化隔离级别。行级锁是指在读取数据时,锁定当前使用的行,防止其他事务同时对该行进行修改,保证当前事务读取的是一致的数据。对于幻影行问题,当一个事务在执行查询时,如果发现其他事务正在进行插入、更新或删除操作,该事务会锁定当前查询的行,直到其他事务操作完成后再进行查询,从而避免出现幻影行。
使用串行化隔离级别时,所有事务都将被串行化执行,即每个事务执行时都需要等待前一个事务执行完成后才能开始执行,从而避免出现幻影行。在串行化隔离级别下,所有的数据读取和修改操作都需要通过共享锁或独占锁来保证数据的一致性和可靠性。虽然串行化隔离级别可以解决幻影行的问题,但由于会对并发性能造成较大的影响,因此只有在确实需要时才应该使用。