目录
2、JVM内存分哪几个区,每个区的作用是什么 难度系数:⭐⭐
4、如何判断一个对象是否存活(或者GC对象的判定方法) 难度系数:⭐
5、什么情况下会产生StackOverflowError(栈溢出)和OutOfMemoryError(堆溢出)怎么排查 难度系数:⭐⭐
9、ThreadPoolExecutor对象有哪些参数 怎么设定核心线程数和最大线程数 拒绝策略有哪些 难度系数:⭐
12、synchronized底层实现是什么 lock底层是什么 有什么区别 难度系数:⭐⭐⭐
13、了解ConcurrentHashMap吗 为什么性能比HashTable高,说下原理 难度系数:⭐⭐
14、ConcurrentHashMap底层原理 难度系数:⭐⭐⭐
16、synchronized和volatile有什么区别 难度系数:⭐⭐
19、简述java内存分配与回收策略以及Minor GC和Major GC(full GC) 难度系数:⭐⭐
1、HashMap底层源码 难度系数:⭐⭐⭐
HashMap在JDK 1.8之前的实现主要基于数组和链表,用于存储键值对。其实现原理可以从以下几个方面来理解:
-
数据结构:HashMap内部维护一个Node数组,用于存储数据。每个Node实际上是一个链表的节点,当发生哈希冲突时(即两个或多个键的哈希值相同),这些键对应的值会被存储在同一个链表中。
-
哈希函数:HashMap使用哈希函数来计算键的哈希值。这个哈希值用于确定键在数组中的存储位置。理想情况下,哈希函数应该能够将键均匀地分布到数组的各个位置,以减少哈希冲突的发生。
-
哈希冲突解决:当两个或多个键的哈希值相同时,就发生了哈希冲突。在JDK 1.8之前的版本中,HashMap使用链表来解决哈希冲突。所有哈希值相同的键会被链接在同一个链表中,通过遍历链表可以找到对应的值。
-
索引计算:为了确定键在数组中的存储位置,HashMap使用哈希值与数组长度减一的按位与运算(hash & (table.length - 1))来计算索引。这种计算方式可以确保结果始终在数组的有效索引范围内。
-
扩容机制:当HashMap中的元素数量超过数组长度的某个阈值(通常是数组长度的75%)时,会触发扩容操作。扩容会创建一个新的、容量更大的数组,并将原数组中的元素重新计算哈希值并存储到新数组中。这样可以确保HashMap在高负载情况下仍然能够保持较高的性能。
需要注意的是,JDK 1.8及以后的版本中,HashMap的实现进行了一些优化,如引入了红黑树来优化长链表的性能。但在JDK 1.8之前的版本中,主要使用的是数组和链表来实现HashMap。
2、JVM内存分哪几个区,每个区的作用是什么 难度系数:⭐⭐
JVM(Java虚拟机)的内存区域主要分为线程私有区域和线程共享区域,以及直接内存。线程私有区域包括程序计数器、虚拟机栈和本地方法栈,它们随着线程的启动而创建,随线程的结束而销毁。线程共享区域则包括堆和方法区,它们随虚拟机的启动而创建,随虚拟机的关闭而销毁。以下是每个区域的具体作用:
-
程序计数器(Program Counter, PC):这是一块较小的内存区域,每个线程都有一个独立的程序计数器。它主要用于指示当前线程执行的字节码指令的位置,确保线程切换后能恢复到正确的执行位置。程序计数器的生命周期与线程的生命周期相同。
-
虚拟机栈(Virtual Machine Stack):虚拟机栈用于描述Java方法的执行过程。每个运行中的线程都有一个活动栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口以及运行时数据及其数据结构等信息。局部变量表存放了编译期可知的数据类型、对象引用和返回地址类型。每个方法执行时都会同步创建一个栈帧,并在方法执行完毕后回收其对应栈内的数据。
-
本地方法栈(Native Method Stack):本地方法栈与虚拟机栈的作用非常相似,其区别在于虚拟机栈为执行Java方法服务,而本地方法栈则为执行Native方法服务。
-
堆(Heap):堆是JVM管理的内存中最大的一块,几乎所有的对象实例都在这里分配内存。堆区里面又区分有新生代和老年代,细分是为了更好地分配和回收内存。
-
方法区(Method Area):主要用于存储加载的类型信息、常量、静态变量和即时编译器编译后的代码缓存等数据。在JDK8中,方法区中的永久代已经被废弃,改用本地内存的元空间,以加载更多的类型信息。
3、Java中垃圾收集的方法有哪些 难度系数:⭐
-
引用计数法:给对象添加一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一;当引用失效时,计数器就减一。当计数器的值为0时,该对象即可被回收。但这种方法存在循环引用的问题,因此在实际应用中并不常用。
-
标记清除算法:这是Java垃圾收集器的一种常见算法。在执行标记阶段时,垃圾收集器会从根节点开始遍历对象图,将所有可达的对象进行标记。然后,清除阶段会清除所有未被标记的对象,释放其所占用的空间。
-
复制算法:复制算法将可用内存空间划分为两个相等的部分,每次只使用其中一个部分进行内存分配。当这部分内存空间满后,会将存活的对象复制到另一块未被使用的空间中,并清除原来的空间。这种算法适用于对象存活率较低的情况。
-
标记整理算法:标记整理算法首先会标记出所有活动的对象,然后将这些对象移到内存的一端,最后清除未被标记的对象,释放其所占用的空间。这种算法可以更有效地利用内存空间,减少内存碎片。
4、如何判断一个对象是否存活(或者GC对象的判定方法) 难度系数:⭐
在Java中,判断一个对象是否存活(即是否应该被垃圾收集器回收)通常涉及到垃圾收集器如何确定哪些对象是不再被引用的。这个过程是通过可达性分析算法来完成的,难度系数相对适中,因为它主要依赖于JVM的内部机制和垃圾收集器的实现。
可达性分析算法
可达性分析算法的基本思想是通过一系列称为“GC Roots”的根对象作为起始点,从这些根对象开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连(即该对象不可达)时,则证明此对象是不可用的,可以被垃圾收集器回收。
GC Roots
在Java中,可以作为GC Roots的对象包括:
-
栈帧中的局部变量表所引用的对象:比如方法执行过程中用到的参数、局部变量等。
-
静态属性引用的对象:比如类的静态成员变量引用的对象。
-
常量引用的对象:比如字符串常量池(String Table)里的引用。
-
本地方法栈中JNI(即一般说的Native方法)引用的对象。
对象的存活判断
基于可达性分析算法,如果一个对象没有任何引用链与GC Roots相连,那么这个对象就被判定为不再存活,可以被垃圾收集器回收。垃圾收集器会周期性地运行,找出并清理这些不再存活的对象,释放它们占用的内存空间。
难度系数分析
从理解的角度来看,可达性分析算法的概念相对直观,理解起来并不困难。然而,在实际应用中,由于垃圾收集器的实现细节和JVM的内部机制可能相对复杂,因此深入理解和分析垃圾收集过程可能需要一定的专业知识和经验。此外,不同的JVM实现和垃圾收集器算法可能会有所不同,这也增加了理解和分析的难度。
5、什么情况下会产生StackOverflowError(栈溢出)和OutOfMemoryError(堆溢出)怎么排查 难度系数:⭐⭐
StackOverflowError
和 OutOfMemoryError
是 Java 程序中常见的两种错误,它们分别对应着不同的内存区域问题。
StackOverflowError(栈溢出)
产生原因:
StackOverflowError
通常发生在递归调用过深,或者一个线程请求的栈深度大于 JVM 所允许的栈深度时。每个线程在创建时都会分配一个栈空间,这个栈空间的大小是有限的。当栈空间不足以容纳更多的方法调用时,就会抛出 StackOverflowError
。
排查方法:
- 查看错误日志:错误日志通常会提供导致栈溢出的方法调用栈信息,通过查看这些信息,可以定位到是哪个方法调用导致了栈溢出。
- 分析代码:重点检查是否有递归调用,并且递归没有正确的退出条件,或者是否有大量的局部变量导致栈空间不足。
- 调整栈大小:如果确认是栈空间不足导致的错误,可以尝试调整 JVM 启动参数
-Xss
来增加每个线程的栈大小。但请注意,过大的栈空间可能会导致更多的内存消耗。
OutOfMemoryError(堆溢出)
产生原因:
OutOfMemoryError
通常发生在堆内存不足时。堆内存是 JVM 中用于存储对象实例的区域,当堆内存中的对象数量过多或者对象过大,导致无法再分配新的内存空间给对象时,就会抛出 OutOfMemoryError
。
排查方法:
- 查看错误日志:错误日志通常会提供导致堆溢出的详细信息,比如是哪个类型的对象占用了过多的内存。
- 使用内存分析工具:如 MAT (Memory Analyzer Tool)、VisualVM 等工具,可以帮助分析堆内存中的对象及其占用情况,找出内存泄漏的根源。
- 代码审查:检查代码中是否有大量创建对象而不释放的情况,或者是否有静态集合类不断添加对象而没有清理。
- 调整堆大小:如果确认是堆空间不足导致的错误,可以尝试调整 JVM 启动参数
-Xms
和-Xmx
来增加堆的初始大小和最大大小。但请注意,过大的堆空间可能会导致更长的垃圾收集时间和更高的内存消耗。
6、什么是线程池,线程池有哪些(创建) 难度系数:⭐
线程池就是事先将多个线程对象放到一个容器中,当使用的时候就不用 new 线程而是直接去池中拿线程即可,节省了开辟子线程的时间,提高的代码执行效率
在 JDK 的 java.util.concurrent.Executors 中提供了生成多种线程池的静态方法。
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
ExecutorService newFixedThreadPool = Executors.newFixedThreadPool(4);
ScheduledExecutorService newScheduledThreadPool = Executors.newScheduledThreadPool(4);
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
然后调用他们的 execute 方法即可。
这4种线程池底层 全部是ThreadPoolExecutor对象的实现,阿里规范手册中规定线程池采用ThreadPoolExecutor自定义的,实际开发也是。
newCachedThreadPool
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。这种类型的线程池特点是:
工作线程的创建数量几乎没有限制(其实也有限制的,数目为Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程。
如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。
在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统瘫痪。
newFixedThreadPool
创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。FixedThreadPool是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
newSingleThreadExecutor
创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。
newScheduleThreadPool
创建一个定长的线程池,而且支持定时的以及周期性的任务执行。例如延迟3秒执行
7、为什么要使用线程池 难度系数:⭐
使用线程池的主要原因包括:
-
降低资源开销:线程池预先创建一定数量的线程,当需要处理任务时,直接从线程池中获取已经创建好的线程,避免了频繁地创建和销毁线程所带来的开销。这样可以显著提高系统的性能。
-
控制并发线程数量:线程池可以限制同时执行的线程数量,防止因过多线程导致系统资源耗尽或性能下降的问题。这对于维护系统的稳定性和响应速度至关重要。
-
提高响应速度:由于线程池中的线程可以复用,减少了线程创建的时间,从而提高了任务的响应速度。
-
提高线程的可管理性:线程是一种稀缺资源,若不加以限制,不仅会占用大量资源,而且会影响系统的稳定性。线程池可以对线程的创建与停止、线程数量等因素加以控制,使得线程在一种可控的范围内运行,这不仅能保证系统稳定运行,而且方便性能调优。
-
异步非阻塞的快速响应:线程池可以将可预见的会发生阻塞操作的代码块部分放入线程池进行执行,当主线程执行到线程池的部分时,会执行线程池的
run()
方法,然后主线程会继续向下执行,直到最后直接返回结果,而阻塞的部分将在run()
方法中执行,不会阻塞主线程的执行,这样可以达到一个异步非阻塞的快速响应。
8、线程池底层工作原理 难度系数:⭐
第一步:线程池的初始化
当线程池刚被创建时,它并不包含任何线程。只有当任务提交到线程池时,线程池才会根据配置创建线程。当然,也可以调用prestartAllCoreThreads()
或prestartCoreThread()
方法预先创建核心线程(corePoolSize个)。
第二步:任务提交与线程创建
当调用execute()
方法提交一个任务时,线程池会检查当前的活动线程数。如果活动线程数小于核心线程数(corePoolSize),线程池会立即创建一个新线程来执行这个任务。
第三步:任务队列
如果当前工作线程数量已经达到或超过核心线程数,线程池会将提交的任务放入一个任务队列中等待执行。这个队列起到了缓冲的作用,使得线程池能够平滑地处理大量任务的提交。
第四步:扩展线程池
如果任务队列已满,并且线程池中工作线程的数量小于最大线程数(maximumPoolSize),线程池会创建新的线程来执行任务。这个机制允许线程池在必要时增加线程数量,以应对突发的任务负载。
第五步:拒绝策略
如果任务队列已满,并且线程池中的线程数量已经达到最大线程数,此时再提交新的任务,线程池就会执行拒绝策略。Java线程池默认的策略是AbortPolicy
,它会抛出RejectedExecutionException
异常。当然,线程池也支持其他几种拒绝策略,如CallerRunsPolicy
(调用者运行策略)、DiscardOldestPolicy
(丢弃最旧任务策略)和自定义的拒绝策略。
9、ThreadPoolExecutor对象有哪些参数 怎么设定核心线程数和最大线程数 拒绝策略有哪些 难度系数:⭐
corePoolSize:核心线程池的大小
maximumPoolSize:线程池能创建线程的最大个数
keepAliveTime:空闲线程存活时间
unit:时间单位,为 keepAliveTime 指定时间单位
workQueue:阻塞队列,用于保存任务的阻塞队列
threadFactory:创建线程的工程类
handler:饱和策略(拒绝策略)
10、常见线程安全的并发容器有哪些 难度系数:⭐
常见的线程安全的并发容器主要包括以下几种:
-
ConcurrentHashMap:这是一个线程安全的哈希表,它支持高并发读写操作。通过将整个数据集分成多个段(Segment)来实现并发控制,不同的线程可以独立地访问不同的段,从而减少了锁的竞争。
-
CopyOnWriteArrayList:这是一个线程安全的动态数组,它采用写时复制的策略。在进行写操作(如添加、修改或删除元素)时,会先将原始数据复制一份,然后在复制的数据上进行操作,确保读操作不会受到写操作的影响。这种策略使得它在读多写少的场景中效率较高,但实时性不高。
-
CopyOnWriteArraySet:这个容器使用CopyOnWriteArrayList实现了Set的相关方法,因此也具有线程安全性。
-
ConcurrentSkipListMap:这个容器内部是通过跳表来实现的,支持高并发的读写操作。
-
ConcurrentSkipListSet:这个容器使用ConcurrentSkipListMap实现了Set的相关方法,因此也具有线程安全性。
此外,还有一些队列(Queue)和双端队列(Deque)也是线程安全的并发容器,它们也常被用于高并发场景。
11、Atomic原子类了解多少 原理是什么 难度系数:⭐
Atomic原子类的原理主要基于以下几个方面:
- 使用CAS(Compare And Swap)操作:CAS是一种乐观锁机制,包含三个参数:内存位置(变量的内存地址)、期望值和新值。该操作会先比较内存位置上的值是否等于期望值,如果相等,则将内存位置上的值修改为新值;如果不相等,则说明该变量已经被其他线程修改过,操作失败。Java中的Atomic类使用CAS操作来实现原子性。
- 使用volatile关键字:volatile关键字可以确保多线程环境下变量的可见性和有序性。
- 底层的本地方法:Atomic类还利用了一些底层的本地方法来实现其原子性。
根据使用范围,Atomic原子类可以分为以下四种类型:
- 原子更新基本类型:如
AtomicInteger
、AtomicLong
等,用于原子地更新基本数据类型的值。 - 原子更新数组:包括
AtomicIntegerArray
、AtomicLongArray
和AtomicReferenceArray
等,用于原子地更新数组中的元素。 - 原子更新引用:如
AtomicReference
,用于原子地更新对象引用。 - 原子更新属性:如果需要更新某个对象中的某个字段,可以使用更新对象字段的原子类,如
AtomicIntegerFieldUpdater
等。
12、synchronized底层实现是什么 lock底层是什么 有什么区别 难度系数:⭐⭐⭐
synchronized
和 Lock
都是 Java 中用于解决并发编程时线程安全问题的工具,但它们有不同的实现方式和特点。
synchronized 底层实现
synchronized
是 Java 中的一个关键字,它的实现是基于 JVM 层面的锁机制。
- JVM 锁机制:JVM 通过内部的监控机制来识别和管理被
synchronized
修饰的代码块或方法。当一个线程尝试进入一个synchronized
代码块或方法时,它会尝试获取锁;如果锁被其他线程持有,则当前线程会阻塞,直到锁被释放。 - 对象锁和类锁:
synchronized
可以作用于实例方法或静态方法,以及代码块。作用于实例方法时,它锁定的是当前实例对象;作用于静态方法时,它锁定的是 Class 对象;作用于代码块时,它锁定的是指定的对象。 - 可重入性:
synchronized
锁是可重入的,即同一个线程可以多次获得同一个锁。
Lock 底层实现
Lock
是 Java 并发包 java.util.concurrent.locks
中的一个接口,它的实现通常是基于 AQS(AbstractQueuedSynchronizer)的。
- AQS:AQS 是一个用于构建锁和同步器的框架,它使用一个 int 类型的变量来表示状态,并通过 CAS(Compare-and-Swap)操作来确保状态更新的原子性。AQS 维护了一个 FIFO 的等待队列,用于管理等待获取锁的线程。
- 显式的获取和释放锁:与
synchronized
的隐式锁机制不同,Lock
需要显式地调用lock()
方法来获取锁,并在合适的时候调用unlock()
方法来释放锁。 - 更灵活的锁策略:
Lock
接口提供了更多的方法,如tryLock()
(尝试获取锁,如果锁不可用则立即返回)、lockInterruptibly()
(可中断地获取锁)等,使得锁策略更加灵活。
区别
- 锁获取方式:
synchronized
是隐式的,而Lock
是显式的。 - 等待可中断:
Lock
提供了可中断的获取锁的方式,而synchronized
不可中断,除非加锁的代码块抛出异常或正常执行完毕。 - 锁绑定多个条件:
Lock
可以绑定多个条件(Condition
对象),从而实现更复杂的线程同步控制,而synchronized
不行。 - 公平性和非公平性:
Lock
接口可以支持公平锁和非公平锁,而synchronized
只能是非公平的。
13、了解ConcurrentHashMap吗 为什么性能比HashTable高,说下原理 难度系数:⭐⭐
区别对比一(HashMap 和 HashTable 区别):
1、HashMap 是非线程安全的,HashTable 是线程安全的。
2、HashMap 的键和值都允许有 null 值存在,而 HashTable 则不行。
3、因为线程安全的问题,HashMap 效率比 HashTable 的要高。
4、Hashtable 是同步的,而 HashMap 不是。因此,HashMap 更适合于单线
程环境,而 Hashtable 适合于多线程环境。一般现在不建议用 HashTable, ①
是 HashTable 是遗留类,内部实现很多没优化和冗余。②即使在多线程环境下,
现在也有同步的 ConcurrentHashMap 替代,没有必要因为是多线程而用
HashTable。
区别对比二(HashTable 和 ConcurrentHashMap 区别):
HashTable 使用的是 Synchronized 关键字修饰,ConcurrentHashMap 是
JDK1.7 使用了锁分段技术来保证线程安全的。JDK1.8ConcurrentHashMap 取消了
Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8
的结构类似,数组+链表/红黑二叉树。
synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就
不会产生并发,效率又提升 N 倍。
14、ConcurrentHashMap底层原理 难度系数:⭐⭐⭐
ConcurrentHashMap
是 Java 并发包中提供的一个线程安全的哈希表实现。它支持高并发场景下的快速读写操作,并且具有相对较低的锁争用。下面我们来详细探讨 ConcurrentHashMap
的底层原理:
分段锁
ConcurrentHashMap
的核心原理是分段锁(Segmentation Lock),它将整个哈希表分割成多个段(Segment),每个段维护着哈希表的一部分数据。每个段都有自己的锁,这样多个线程可以同时访问不同的段,从而实现真正的并发访问。
每个段内部实际上是一个小的哈希表,其内部的数据结构(如数组和链表)与普通的 HashMap
类似。当需要访问某个键时,ConcurrentHashMap
会根据哈希值定位到相应的段,并在该段上进行操作。由于每个段都有自己的锁,因此不同线程可以同时访问不同的段,而不会相互干扰。
读写锁分离
除了分段锁之外,ConcurrentHashMap
还采用了读写锁分离的策略。对于读操作,ConcurrentHashMap
允许多个线程同时访问同一个段,因为读操作不会修改数据,所以不会引发数据不一致的问题。而对于写操作(如 put 和 remove),则需要获取相应段的写锁,以确保在修改数据时的线程安全。
CAS 操作
在某些情况下,ConcurrentHashMap
还会使用 CAS(Compare-and-Swap)操作来确保操作的原子性。CAS 是一种无锁技术,它可以在多线程环境下实现无锁的数据修改。通过比较内存位置的值和期望值,如果相等则更新为新值,否则重试,这种方式可以避免锁的竞争,提高并发性能。
扩容机制
当 ConcurrentHashMap
中的元素数量超过某个阈值时,它会触发扩容操作。与普通的 HashMap
不同,ConcurrentHashMap
的扩容是逐步进行的,而不是一次性重新分配整个哈希表。这种逐步扩容的方式可以减少扩容过程中对性能的影响。
总结
ConcurrentHashMap
通过分段锁、读写锁分离、CAS 操作和逐步扩容等机制,实现了高并发场景下的线程安全和高效访问。这使得它在处理大量并发读写操作时具有出色的性能表现。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
// hash 值无符号右移 28位(初始化时获得),然后与 segmentMask=15 做与运算
// 其实也就是把高4位与segmentMask(1111)做与运算
// this.segmentMask = ssize - 1;
//对hash值进行右移segmentShift位,计算元素对应segment中数组下表的位置
//把hash右移segmentShift,相当于只要hash值的高32-segmentShift位,右移的目的是保留了hash值的高位。然后和segmentMask与操作计算元素在segment数组中的下表
int j = (hash >>> segmentShift) & segmentMask;
//使用unsafe对象获取数组中第j个位置的值,后面加上的是偏移量
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 如果查找到的 Segment 为空,初始化
s = ensureSegment(j);
//插入segment对象
return s.put(key, hash, value, false);
}
/**
* Returns the segment for the given index, creating it and
* recording in segment table (via CAS) if not already present.
*
* @param k the index
* @return the segment
*/
@SuppressWarnings("unchecked")
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
// 判断 u 位置的 Segment 是否为null
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
// 获取0号 segment 里的 HashEntry<K,V> 初始化长度
int cap = proto.table.length;
// 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的
float lf = proto.loadFactor;
// 计算扩容阀值
int threshold = (int)(cap * lf);
// 创建一个 cap 容量的 HashEntry 数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck
// 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 自旋检查 u 位置的 Segment 是否为null
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
// 使用CAS 赋值,只会成功一次
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 获取 ReentrantLock 独占锁,获取不到,scanAndLockForPut 获取。
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
// 计算要put的数据位置
int index = (tab.length - 1) & hash;
// CAS 获取 index 坐标的值
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
// 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
// first 有值没说明 index 位置已经有值了,有冲突,链表头插法。
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 容量大于扩容阀值,小于最大容量,进行扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
// index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
15、了解volatile关键字不 难度系数:⭐
volatile
是 Java 中的一个关键字,主要用于确保多线程环境下变量的可见性和禁止指令重排。以下是 volatile
关键字的详细解释:
可见性
在多线程环境中,每个线程都有自己的工作内存(本地缓存),当线程从主内存中读取一个变量到工作内存后,本地内存中的变量副本就独立于主内存中的变量,线程对变量的修改如果没有写回到主内存,其他线程是感知不到的。
volatile
关键字的作用之一就是保证变量的可见性。当一个变量被声明为 volatile
,它会确保所有线程看到这个变量的值是一致的。当一个线程修改了这个变量的值,新值对其他线程来说是立即可见的。这主要是因为 volatile
修饰的变量在每次被线程访问时,都会直接从主内存中读取,而不是从线程的本地缓存中读取。同样,对该变量的修改也会立即写回主内存,而不是留在本地缓存中。
禁止指令重排
编译器和处理器为了提高性能,会对输入的代码进行指令重排(Instruction Reordering)。但是,这种重排可能会破坏多线程程序的语义。volatile
关键字的另一个作用就是禁止指令重排,从而确保程序按照预期的顺序执行。
16、synchronized和volatile有什么区别 难度系数:⭐⭐
volatile 本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从
主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线
程被阻塞住。
volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的。
volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证
变量的修改可见性和原子性。
volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。
17、Java类加载过程 难度系数:⭐
Java类加载过程是一个复杂但重要的机制,它涉及了类的生命周期中的多个阶段。以下是Java类加载过程的主要步骤:
-
加载(Loading):
-
这是类加载的第一个阶段。在这个阶段,Java虚拟机(JVM)通过类的全名获取类的二进制字节流。
-
这个字节流可以从网络上获取,或者从本地文件系统、压缩包中等多种方式获得。
-
获取到字节流后,JVM会将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,然后在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
-
-
链接(Linking):
-
链接阶段又可以分为验证、准备和解析三个子阶段。
-
验证(Verification):确保被加载的类的正确性和安全性。
-
准备(Preparation):为类的静态变量分配内存,并将其初始化为默认值(如int类型初始化为0,引用类型初始化为null)。
-
解析(Resolution):把类中的符号引用转换为直接引用。
-
-
初始化(Initialization):
-
这是类加载过程的最后一步。
-
在这个阶段,执行类构造器
<clinit>()
方法的方法体。此方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块(static{}块)中的语句合并产生的。 -
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
-
虚拟机会保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁和同步,确保只会有一个线程去执行这个类的<clinit>()
方法。
-
18、什么是类加载器,类加载器有哪些 难度系数:⭐
类加载器(ClassLoader)是Java语言中的一种机制,负责加载字节码文件(.class)到Java虚拟机(JVM)中,使得这些类能够被JVM执行。在Java中,每个类都是由类加载器加载的,而类加载器本身也是一个类,其实质是把类文件从硬盘读取到内存中。
类加载器在Java中主要有以下几种:
- 启动类加载器(BootstrapClassLoader):用于加载Java的核心类库,如rt.jar中的JDK类文件。它是所有类加载器的父加载器,无法被Java程序直接引用。
- 扩展类加载器(ExtensionClassLoader):用于加载Java的扩展库。它会在Java虚拟机提供的扩展库目录里面查找并加载Java类。如果没有成功加载,还会从jre/lib/ext目录下或者java.ext.dirs系统属性定义的目录下加载类。
- 系统类加载器(SystemClassLoader):也被称为应用类加载器(ApplicationClassLoader)。它根据Java应用的类路径(CLASSPATH)来加载Java类。一般来说,Java应用的类都是由它来完成加载的。
- 用户自定义类加载器:这是开发人员根据自己的需求自定义的类加载器,通过继承java.lang.ClassLoader类的方式实现。它们具有更高的灵活性,可以实现特殊的类加载策略,例如从网络加载类或者实现类的热部署等。
类加载器在Java类加载过程中起着至关重要的作用,它们按照特定的规则和顺序来加载类,确保了Java程序的正常运行。同时,类加载器的双亲委派模型也保证了Java类库的统一性和安全性。
19、如何查看java死锁 难度系数:⭐
####演示死锁
package com.ssg.mst;
public class 死锁 {
private static final String lock1 = "lock1";
private static final String lock2 = "lock2";
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (true) {
synchronized (lock1) {
try {
System.out.println(Thread.currentThread().getName() + lock1);
Thread.sleep(1000);
synchronized (lock2){
System.out.println(Thread.currentThread().getName() + lock2);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
Thread thread2 = new Thread(() -> {
while (true) {
synchronized (lock2) {
try {
System.out.println(Thread.currentThread().getName() + lock2);
Thread.sleep(1000);
synchronized (lock1){
System.out.println(Thread.currentThread().getName() + lock1);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
thread1.start();
thread2.start();
}
}
Java死锁是并发编程中一个常见且重要的问题。当两个或更多线程在等待对方释放资源时,会发生死锁,导致这些线程都无法继续执行。死锁会导致程序性能下降,甚至使程序完全停止响应。因此,正确识别、预防和处理Java死锁是确保并发程序稳定运行的关键。
以下是对Java死锁的一些看法:
-
识别死锁:首先,要能够识别出程序中是否存在死锁。Java提供了多种工具和技术来帮助我们检测死锁,如使用jstack工具查看线程堆栈信息,或者使用IDE中的调试功能。通过观察线程堆栈,我们可以发现哪些线程相互等待对方释放资源,从而确定是否存在死锁。
-
预防死锁:预防死锁是更好的选择,因为一旦死锁发生,解决起来可能会比较困难。预防死锁的策略包括:
-
避免嵌套锁:尽量保持锁的获取顺序一致,避免嵌套锁导致的循环等待。
-
使用定时锁:使用带超时的锁获取方法,当无法获取锁时,线程可以放弃等待,从而避免死锁。
-
检测死锁并恢复:在程序中实现死锁检测机制,当检测到死锁时,主动放弃一些资源或重启线程,以打破死锁状态。
-
-
处理死锁:如果程序中发生了死锁,我们需要迅速定位并解决它。处理死锁的方法包括:
-
分析线程堆栈:使用jstack等工具分析线程堆栈,找出导致死锁的线程和资源。
-
释放资源:尝试手动释放被死锁线程持有的资源,以打破循环等待。
-
重启程序:如果无法快速解决死锁,可以考虑重启程序以恢复正常运行。但这种方法可能会导致数据丢失或不一致,因此需要谨慎使用。
-
-
优化并发设计:为了减少死锁的可能性,我们应该对并发设计进行优化。例如,使用更细粒度的锁来减少线程间的竞争;使用读写锁等高级并发工具来提高并发性能;对共享资源进行合理划分和隔离,以减少线程间的依赖关系。
20、Java死锁如何避免 难度系数:⭐
造成死锁的四个主要原因确实如您所述:
-
互斥条件:一个资源每次只能被一个线程使用。这是大多数同步机制的基础,确保了资源在某一时刻只能被一个线程访问。
-
持有并等待条件:一个线程在阻塞等待某个资源时,不释放已占有的资源。这通常发生在线程试图获取多个资源时,它已经持有部分资源,但还在等待其他资源。
-
不可剥夺条件:一个线程已经获得的资源,在未使用完之前,不能被强行剥夺。这意味着线程必须主动释放资源,否则其他线程无法获取。
-
循环等待条件:若干线程形成头尾相接的循环等待资源关系。即每个线程都在等待下一个线程释放资源,而最后一个线程又在等待第一个线程释放资源,形成一个闭环。
为了避免死锁,在开发过程中可以采取以下策略:
-
注意加锁顺序:确保每个线程都按照一致的顺序请求锁。这样可以消除循环等待条件,因为每个线程都按照相同的顺序等待锁,不会出现头尾相接的循环等待。
-
设置加锁时限:使用带有超时的锁获取方法。如果线程在超时时间内无法获取锁,则放弃等待,从而避免无限期的阻塞。这有助于打破持有并等待条件,因为线程在等待一段时间后会主动放弃已持有的资源。
-
实施死锁检测与恢复:在程序中实现死锁检测机制,定期或不定期地检查线程状态和资源占用情况,以便及时发现死锁。一旦发现死锁,可以采取措施如放弃部分资源、终止部分线程或重启系统来打破死锁状态。
-
避免嵌套锁:尽量减少锁的嵌套使用,以减少线程间的依赖关系。如果必须使用嵌套锁,确保内部锁总是在外部锁释放之前被释放。
-
使用高级并发工具:利用Java并发包中提供的高级并发工具,如
Semaphore
、CountDownLatch
、CyclicBarrier
等,它们提供了更灵活的同步机制,有助于避免死锁。 -
充分理解业务需求:在设计并发程序时,要深入理解业务需求,合理划分任务和资源,确保线程间的协作和同步是高效且安全的。