目录
1.HashMap和ConcurrentHashMap区别(必考)
2. ConcurrentHashMap的数据结构(必考)
3.高并发HashMap的环是如何产生的(必考)
4.HashMap1.7与HashMap1.8的区别,从数据结构上、Hash值的计算上、链表数据的插入方法、内部Entry类的实现上分析?
5.HashMap如果我想要让自己的Object作为K应该怎么办?
6.HashMap相关put操作,get操作等流程?(必考)
7.HashSet和HashMap区别
8.Synchronized的实现原理(必考)
9.Hash1.7是基于数组和链表实现的,为什么不用双链表?HashMap1.8中引入红黑树的原因是?为什么要用红黑树而不是平衡二叉树?
10.HashMap、HashTable、ConcurrentHashMap的原理与区别?
11.volatile与synchronized的区别是什么?volatile作用(必考)
12.synchronized和Lock的区别(必考)
13.Atomic类如何保证原子性(CAS操作)(必考)
14.Java不可重入锁与可重入锁的区别如何理解?
15.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁,解释锁升级?(必考)
16.乐观锁 VS 悲观锁?公平锁 VS 非公平锁?独享锁 VS 共享锁?
17.自旋锁 VS 适应性自旋锁,简单介绍
18.AQS理论的数据结构是什么样的?
19.多线程中sleep与wait的区别是什么?
20.final、finnally、finalize的区别是什么?
21.ThreadLocal的原理和实现
22.ThreadLocal为什么要使用弱引用和内存泄露问题
23.为什么要使用线程池(必考)
24.线程池的线程数量确定?状态分析?关闭方式?
25.如何控制线程池线程的优先级
26.线程之间如何通信
27.核心线程池ThreadPoolExecutor的参数(必考)
28.常见线程池的创建参数是什么样的?
29.ThreadPoolExecutor的工作流程(必考)
30.Java线程池的调优经验有哪些?(线程池的合理配置)
30.Boolean占几个字节
31.Exception和Error
32.Object类内的方法
33.Jdk1.8/Jdk1.7都分别新增了哪些特性?其他版本呢?
34.StringBuffer和StringBuilder的区别是什么?性能对比?如何鉴定线程安全?
35.String str="hello world"和String str=new String(“hello world”)的区别?
36.Array和ArrayList有什么区别?使用时注意事项有哪些?
37.LRU算法是怎么实现的?大致说明下(必考)
具体实现方案:使用LinkedHashMap实现
自编代码:基于 HashMap 和 双向链表实现 LRU
其他相关内容补充:LRU-K
其他相关内容补充:two queue
其他相关内容补充:Multi Queue(MQ)
38.CAS?CAS 有什么缺陷,如何解决?(必考)
39.ScheduledThreadPoolExecutor中的使用的是什么队列?内部如何实现任务排序的?
40.Future原理,其局限性是什么?并说说CompletableFuture核心原理?
41.伪共享机制简述分析
42.线程池的运行逻辑,FixedThreadPool、CachedThreadPool的原理(必考)
43.阻塞队列ArrayBlockingQueue、LinkedBlockingQueue分析
44.epoll、select、poll原理
45.Netty对比Java NIO做了什么优化?(必考)
46.线程池关闭原理
47.ThreadLocal原理和注意事项(必考)
48.StringBuffer 和 StringBuilder 底层怎么实现的?
49.一个请求中,计算操作需要50ms,bd操作需要100ms,对于一台8核的机器来说,如果要求cpu利用率达到100%,如何设置线程数?
50.如果系统中不同的请求对应的cpu时间和io时间都不同,那怎么设置线程数量?
51.Java实现多线程的方式有哪些?
52.Java处理多线程的方式有哪些?
53.Java指向的是引用还是地址?怎么理解
54.ReentrantLock底层公平锁和非公平锁的原理
55.Hashmap 线程不安全的原因
56.Hash为啥要扩容
57.进程和线程的区别
58.并发和并行的区别
59.进程调度的策略
60.死锁发生的原因
61.线程池核心数20,最大600,阳塞队列200,当gps200(注意是qps)的时候,请求(请求是调第三方,是一个长时间的任务)阻塞超时,请问怎么提高它的吞吐量(不能加机器)?
62.假设引用了一个第三方的jar 有个类和我自己写的代码类一样,那么在类加载机制过程中是如何处理的?
63.基本类型和包装类区别
64.多线程对Long数据进行加和会存在什么问题?如何解决?
65.用java 代码实现一个死锁用例,说说怎么解决死锁问题?回到用例代码下,如何解决死锁问题呢?
66.Netty 的线程机制是什么样的?
67.当前线程池是200,线程单次处理请求20ms,那么理论上单节点的qps 是多少呢?
68.简单说下Lambda表达式,其解决了什么,相比java7的处理优化了什么?
69.NIO(New I/O)用到的组件有哪些?
70.SpI和API区别是什么?SpI底层实现是什么?
参考书籍、文献和资料
1.HashMap和ConcurrentHashMap区别(必考)
HashMap和ConcurrentHashMap是Java中的两种不同类型的映射(Map)实现。
它们有以下几个区别:
- 线程安全性:最显著的区别是HashMap是非线程安全的,而ConcurrentHashMap是线程安全的。在多线程环境下,多个线程可以同时访问和修改ConcurrentHashMap的不同部分,而不会导致数据不一致或其他并发问题。相反,如果多个线程同时访问和修改HashMap,则可能导致数据损坏或抛出ConcurrentModificationException等异常。
- 锁机制:**在Java 8之前的版本中,ConcurrentHashMap采用了分段锁的机制来实现线程安全。**每个段(Segment)都有自己的锁,可以独立地进行读写操作,从而提高并发性能。但在Java 8及以后的版本中,ConcurrentHashMap的内部结构发生了改变,采用了更为高效的实现方式。**在Java 8及以后的版本中,ConcurrentHashMap使用了一种称为"分段锁升级"(Striped Locking)的机制。**它首先将数据分成一组小的桶(buckets),每个桶都可以独立地进行读写操作。每个桶中的元素可能对应多个键值对,但仍然保持并发安全。当多个线程同时访问ConcurrentHashMap时,会根据键的哈希值选择相应的桶,并使用Synchronized来锁定该桶。这样可以保证在同一个桶中的操作是互斥的,避免并发冲突。而在桶内部的读写操作,则使用了CAS操作来保证并发安全。**因此,结合Synchronized和CAS的机制,ConcurrentHashMap能够在并发环境中提供高效的线程安全性,允许多个线程同时读取和写入不同的桶,而不需要全局的锁。这种实现方式在性能和扩展性方面都有较好的表现。**而HashMap没有锁机制,所以在多线程环境下需要自行实现同步机制来确保线程安全。
- 迭代器:HashMap的迭代器(Iterator)是快速失败的(fail-fast),也就是说,如果在迭代过程中有其他线程修改了HashMap的结构(添加或删除元素),则会抛出ConcurrentModificationException异常。而ConcurrentHashMap的迭代器是弱一致性的(weakly consistent),不会抛出该异常,并且保证迭代器遍历期间能够看到最新的数据状态。
- 性能:由于ConcurrentHashMap使用了并发控制手段,它在高并发环境下能够提供更好的性能表现,允许多个线程同时读取和写入不同的段。相比之下,HashMap在并发情况下需要进行手动的同步操作,性能相对较低。
综上所述,如果需要在多线程环境下使用映射数据结构并且需要高并发性能,则应该使用ConcurrentHashMap。而在单线程环境下或者不需要并发安全的情况下,HashMap是更简单、更高效的选择。
当选择使用HashMap或ConcurrentHashMap时,以下是一些建议:
使用HashMap:
- 在单线程环境下,或者在不需要并发安全性的情况下,使用HashMap是简单和高效的选择。
- 当只有一个线程对Map进行读写操作时,HashMap通常比ConcurrentHashMap性能更好,因为它不需要进行额外的并发控制。
使用ConcurrentHashMap:
- 在多线程环境下,特别是需要高并发性能和线程安全性的情况下,应使用ConcurrentHashMap。
- 当多个线程需要并发读写Map时,ConcurrentHashMap能够提供更好的性能,因为它使用了分段锁(在Java 7及之前的版本)或基于结合Synchronized和CAS的机制算法(在Java 8及之后的版本)来实现并发控制。
- 当需要在遍历ConcurrentHashMap时,它的迭代器提供弱一致性保证,不会抛出ConcurrentModificationException异常,因此更适合在并发环境中进行遍历操作。
总结:
- 如果在单线程环境下,或者不需要并发安全性,使用HashMap。
- 如果在多线程环境下,特别是需要高并发性能和线程安全性,使用ConcurrentHashMap。
请注意,以上建议是一般性的指导原则,具体选择应根据实际需求和场景进行评估和决策。
2. ConcurrentHashMap的数据结构(必考)
在JDK1.7版本中,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。
在JDK1.8版本中,ConcurrentHashMap摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap。
在JDK1.8版本中,对于size的计算,在扩容和addCount()时已经在处理了。JDK1.7是在调用时才去计算。
3.高并发HashMap的环是如何产生的(必考)
重点该问题产生于jdk7中(jdk8已经解决了“环形链表”,其使用的是尾插法),HashMap成环原因的代码出现在transfer代码中,也就是扩容之后的数据迁移部分,代码如下:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
解释一下transfer的过程:首先获取新表的长度,之后遍历新表的每一个entry,然后每个ertry中的链表以反转的形式形成rehash之后的链表。
并发问题:
若当前线程一此时获得entry节点,但是被线程中断无法继续执行,此时线程二进入transfer函数,并把函数顺利执行,此时新表中的某个位置有了节点,之后线程一获得执行权继续执行,因为并发transfer,所以两者都是扩容的同一个链表,当线程一执行到e.next = new table[i] 的时候,由于线程二之前数据迁移的原因导致此时new table[i] 上就有ertry存在,所以线程一执行的时候,会将next节点,设置为自己,导致自己互相使用next引用对方,因此产生链表,导致死循环。
解决问题:
- 使用synchronize
- 使用collection.synchronizeXXX方法
- 使用concurrentHashmap来解决。
4.HashMap1.7与HashMap1.8的区别,从数据结构上、Hash值的计算上、链表数据的插入方法、内部Entry类的实现上分析?
数据结构上
- JDK1.7的时候使用的是数组+ 单链表的数据结构。数组和链表节点的实现类是Entry类。
- 在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)。数组和链表节点的实现类是Node类。
Hash值的计算上
- JDK1.7用了9次扰动处理=4次位运算+5次异或
- JDK1.8只用了2次扰动处理=1次位运算+1次异或。直接用了JDK1.7的时候计算的规律,相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。
链表数据的插入方法上
- JDK1.7用的是头插法,用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。
- JDK1.8及之后使用的都是尾插法,因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
内部Entry类的实现上
- JDK1.7数组和链表节点的实现类是Entry类,实现了Map.entry接口。
- JDK1.8数组和链表节点的实现类是Node类,但是还是实现了Map.entry接口。
5.HashMap如果我想要让自己的Object作为K应该怎么办?
- 重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞;
- 重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性;
6.HashMap相关put操作,get操作等流程?(必考)
以下回答进行简单简述如下。
当调用HashMap的put(key, value)方法时,会执行以下步骤:
- 计算键的哈希码:通过调用键对象的hashCode()方法来获取键的哈希码。
- 定位桶位置:将计算得到的哈希码映射到HashMap的内部数组中的一个桶(bucket)位置。使用哈希码与桶数量取模的方式来确定桶的位置。
- 查找或创建节点:在选定的桶位置上,遍历链表或红黑树(如果存在)以查找是否已存在具有相同键的节点。如果找到相同键的节点,则将新值替换旧值。如果未找到相同键的节点,则创建一个新节点。
- 插入或添加节点:将新节点插入到选定桶位置的链表或红黑树中。
- 判断是否需要调整容量:如果插入节点后,链表长度或红黑树的节点数量超过一定阈值(Java 8中为8),则会触发调整容量的操作,即扩容HashMap的内部数组。
当调用HashMap的get(key)方法时,会执行以下步骤:
- 计算键的哈希码:通过调用键对象的hashCode()方法来获取键的哈希码。
- 定位桶位置:将计算得到的哈希码映射到HashMap的内部数组中的一个桶位置。
- 查找节点:在选定的桶位置上,遍历链表或红黑树(如果存在),通过比较键的相等性(调用键对象的equals()方法)来查找具有相同键的节点。
- 返回节点值:如果找到具有相同键的节点,则返回该节点的值;否则,返回null表示未找到对应的值。
需要注意的是,HashMap使用哈希码和相等性比较来确定键值对的存储位置和查找操作。因此,在自定义对象作为键时,确保正确实现equals()和hashCode()方法非常重要,以保证HashMap的正确性和一致性。
我们重点还是分析put的内容,下图展开方便更深的理解:
7.HashSet和HashMap区别
HashSet和HashMap是Java集合框架中的两个常用类,它们具有一些共同的特点,但也有一些区别。以下是对HashSet和HashMap的对比分析和一些建议:
共同点:
- 底层数据结构:HashSet和HashMap都使用哈希表作为其底层数据结构。它们都通过哈希码来确定元素的存储位置,从而实现快速的查找、插入和删除操作。
- 唯一性:HashSet和HashMap都保证元素的唯一性。在HashSet中,它保证集合中没有重复的元素;在HashMap中,它保证没有重复的键。
区别:
- 存储机制:HashSet是基于HashMap实现的,但它只存储元素的键,值都被设置为同一个固定值(常量PRESENT)。因此,HashSet实际上是一个无序、不重复的集合;而HashMap是键值对的存储结构,可以存储键值对,并且键是唯一的。
- API:HashSet提供了Set接口的方法,而HashMap提供了Map接口的方法。因此,HashSet适合用于需要存储唯一元素的场景,而HashMap适合需要键值对映射关系的场景。
- 迭代顺序:由于HashSet是无序的,迭代元素的顺序是不确定的;而HashMap在迭代时,可以按照插入顺序或者根据键的哈希码顺序进行迭代(通过LinkedHashMap可以实现有序的遍历)。
使用建议:
- 如果你只关注元素的唯一性,并不关心元素的顺序,可以选择HashSet。例如,需要存储一组唯一的字符串、对象等。
- 。例如,需要存储学生的学号和对应的成绩。
- 如果你需要根据插入顺序或者键的哈希码顺序进行迭代,可以选择LinkedHashMap。它是HashMap的子类,可以保持插入顺序或者按键的哈希码顺序进行迭代。
- 如果你需要同时保持唯一性和顺序,可以选择LinkedHashSet。它是HashSet的子类,可以按照插入顺序进行迭代。
最后,根据具体的需求和场景选择适合的集合类是最重要的,这些建议可以帮助你做出合适的选择。
8.Synchronized的实现原理(必考)
Synchronized是Java中用于实现同步的关键字,它可以用于修饰方法或代码块,用于保证多个线程对共享资源的互斥访问。Synchronized的实现原理涉及到Java对象头、Monitor(监视器)以及内置锁的概念。
在Java中,每个对象都有一个与之关联的对象头(Object Header),对象头中包含了一些用于存储对象的元数据信息。其中之一是用于实现同步的标志。
当线程进入一个被synchronized修饰的方法或代码块时,它会尝试获取对象的内置锁(也称为监视器锁),如果锁未被其他线程占用,则线程成功获取锁并进入临界区,开始执行同步代码。
如果锁已经被其他线程持有,线程将进入锁的等待队列,并进入阻塞状态,等待其他线程释放锁。
内置锁是与对象关联的,每个对象都有一个与之对应的Monitor(监视器),Monitor用于管理对象的同步。当一个线程成功获取锁时,Monitor会记录持有锁的线程,并在释放锁时通知等待队列中的其他线程。
在Synchronized的实现过程中,Java虚拟机使用了底层的操作系统原语,如CAS(Compare and Swap)和Mutex(互斥量)等来实现锁的获取和释放。
总结一下,Synchronized的实现原理涉及到对象头、Monitor和内置锁。通过获取对象的内置锁,线程可以进入临界区执行同步代码,如果锁已被其他线程占用,则线程会进入等待队列。Java虚拟机使用底层操作系统原语来实现锁的获取和释放。这样,Synchronized确保了多个线程对共享资源的互斥访问,从而实现了线程安全性。
9.Hash1.7是基于数组和链表实现的,为什么不用双链表?HashMap1.8中引入红黑树的原因是?为什么要用红黑树而不是平衡二叉树?
- 使用链表是为了解决哈希冲突,使用单链表就可以完成,使用双链表需要更大的存储空间。
- 为了提高HashMap的性能,在解决发生哈希碰撞后,链表过长导致索引效率慢的问题,同时红黑树解决快速增删改查特点。
- 红黑树的平衡度相比平衡二叉树要低,对于删除、插入数据之后重新构造树的开销要比平衡二叉树低,查询效率比普通二叉树高,所以选择性能相对折中的红黑树。
10.HashMap、HashTable、ConcurrentHashMap的原理与区别?
以java7为背景情况下回答如下:
HashTable
- 底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
- 初始size为11,扩容:newsize = olesize*2+1
- 计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length
HashMap
- 底层数组+链表实现,可以存储null键和null值,线程不安全
- 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
- 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
- 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
- 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
- 计算index方法:index = hash & (tab.length – 1)
ConcurrentHashMap(简单简述java7的,java8见上面的讲解)
- 底层采用分段的数组+链表实现,线程安全
- 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
- Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
- 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
- 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容
11.volatile与synchronized的区别是什么?volatile作用(必考)
背景知识了解
- Java的线程抽象内存模型
Java的线程抽象内存模型中定义了每个线程都有一份自己的私有内存,里面存放自己私有的数据,其他线程不能直接访问,而一些共享数据则存在主内存中,供所有线程进行访问。
上图中,如果线程A和线程B要进行通信,就要经过主内存,比如线程B要获取线程A修改后的共享变量的值,要经过下面两步:
(1)、线程A修改自己的共享变量副本,并刷新到了主内存中。
(2)、线程B读取主内存中被A更新过的共享变量的值,同步到自己的共享变量副本中。
-
Java多线程中的原子性、可见性、有序性
(1)、原子性:是指线程的多个操作是一个整体,不能被分割,要么就不执行,要么就全部执行完,中间不能被打断。
(2)、可见性:是指线程之间的可见性,就是一个线程修改后的结果,其他的线程能够立马知道。
(3)、有序性:为了提高执行效率,java中的编译器和处理器可以对指令进行重新排序,重新排序会影响多线程并发的正确性,有序性就是要保证不进行重新排序(保证线程操作的执行顺序)。 -
volatile关键字的作用
volatile关键字的作用就是保证了可见性和有序性(不保证原子性),如果一个共享变量被volatile关键字修饰,那么如果一个线程修改了这个共享变量后,其他线程是立马可知的。
为什么是这样的呢?比如,线程A修改了自己的共享变量副本,这时如果该共享变量没有被volatile修饰,那么本次修改不一定会马上将修改结果刷新到主存中,如果此时B去主存中读取共享变量的值,那么这个值就是没有被A修改之前的值。如果该共享变量被volatile修饰了,那么本次修改结果会强制立刻刷新到主存中,如果此时B去主存中读取共享变量的值,那么这个值就是被A修改之后的值了。
volatile能禁止指令重新排序,在指令重排序优化时,在volatile变量之前的指令不能在volatile之后执行,在volatile之后的指令也不能在volatile之前执行,所以它保证了有序性。
- synchronized关键字的作用
synchronized提供了同步锁的概念,被synchronized修饰的代码段可以防止被多个线程同时执行,必须一个线程把synchronized修饰的代码段都执行完毕了,其他的线程才能开始执行这段代码。
因为synchronized保证了在同一时刻,只能有一个线程执行同步代码块,所以执行同步代码块的时候相当于是单线程操作了,那么线程的可见性、原子性、有序性(线程之间的执行顺序)它都能保证了。
volatile关键字和synchronized关键字的区别
(1)、volatile只能作用于变量,使用范围较小。synchronized可以用在变量、方法、类、同步代码块等,使用范围比较广。
(2)、volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、原子性synchronized都可以保证。
(3)、volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。
12.synchronized和Lock的区别(必考)
背景知识了解
- synchronized
Java语言的关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。然而,当一个线程访问object的一个加锁代码块时,另一个线程仍然可以访问该object中的非加锁代码块。
- lock
(1)synchronized的缺陷
synchronized是java中的一个关键字,也就是说是Java语言内置的特性。那么为什么会出现Lock呢?
如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
2)线程执行发生异常,此时JVM会让线程自动释放锁。
那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待,试想一下,这多么影响程序执行效率。因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。
再举个例子:当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是采用synchronized关键字来实现同步的话,就会导致一个问题:如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,通过Lock就可以办到。
另外,通过Lock可以知道线程有没有成功获取到锁。这个是synchronized无法办到的。
总结一下,也就是说Lock提供了比synchronized更多的功能。但是要注意以下几点:
1)Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;
2)Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。
(2)java.util.concurrent.locks包下常用的类
public interface Lock {
/*获取锁,如果锁被其他线程获取,则进行等待*/
void lock();
/**当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,
即中断线程的等待状态。也就使说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,
假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。*/
void lockInterruptibly() throws InterruptedException;
/**tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成
*功,则返回true,如果获取失败(即锁已被其他线程获取),则返回
*false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。*/
boolean tryLock();
/*tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,
只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。
如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock(); //释放锁
Condition newCondition();
}
注意:
当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。
(3)ReentrantLock
ReentrantLock,意思是“可重入锁”,是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。
如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。
举个简单的例子,当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。
class MyClass {
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
}
synchronized和lock区别
1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;
而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
5)Lock可以提高多个线程进行读操作的效率。
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
13.Atomic类如何保证原子性(CAS操作)(必考)
前提知识:Atomic 内部的value 使用volatile保证内存可见性,使用CAS保证原子性
- volatile保证内存可见性:
打开AtomicInteger的源码可以看到:
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value;
volatile关键字用来保证内存的可见性(但不能保证线程安全性),线程读的时候直接去主内存读,写操作完成的时候立即把数据刷新到主内存当中。
- 使用CAS保证原子性:
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
从注释就可以看出:当线程写数据的时候,先对内存中要操作的数据保留一份旧值,真正写的时候,比较当前的值是否和旧值相同,如果相同,则进行写操作。如果不同,说明在此期间值已经被修改过,则重新尝试。
compareAndSet使用Unsafe调用native本地方法CAS(CompareAndSet)递增数值。CAS利用CPU调用底层指令实现。
两种方式:总线加锁或者缓存加锁保证原子性。
14.Java不可重入锁与可重入锁的区别如何理解?
更详细的见文章:可重入锁 VS 非可重入锁
可重入锁(Reentrant Lock)和不可重入锁(Non-reentrant Lock)是锁的两种不同实现方式,其主要区别在于是否支持同一个线程多次获取同一把锁。
**可重入锁允许同一个线程多次获取同一把锁,而不可重入锁不允许同一个线程多次获取同一把锁。**具体来说,可重入锁会维护一个获取锁的计数器,每次成功获取锁时,计数器会加1;线程释放锁时,计数器会减1。只有当计数器归零时,其他线程才能获取该锁。这样,同一个线程在持有锁的情况下,可以再次获取同一把锁而不会被阻塞,称为锁的重入性。
**不可重入锁则不支持同一个线程多次获取同一把锁。**当一个线程已经持有该锁时,再次尝试获取同一把锁会导致线程被阻塞,直到其他线程释放该锁。
理解可重入锁和不可重入锁的区别有助于避免死锁和实现复杂的同步逻辑。可重入锁能够适应更复杂的同步需求,允许在同一线程中递归地调用同步方法或代码块,而不可重入锁则需要谨慎使用,以防止死锁和逻辑错误。
在Java中,synchronized关键字实现的锁是可重入锁,即同一个线程在持有锁的情况下可以再次获取同一把锁。而ReentrantLock类也是可重入锁的实现,它提供了更多灵活性和扩展性,可以用于更复杂的同步场景。
15.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁,解释锁升级?(必考)
在Java中,锁的升级是指在多线程竞争的情况下,从低级别的锁逐渐升级到高级别的锁。Java的锁升级过程包括无锁、偏向锁、轻量级锁和重量级锁,每个级别的锁都有不同的开销和适用场景。
- 无锁:在无竞争的情况下,线程可以自由地访问共享数据,无需任何锁机制。
- 偏向锁(Biased Locking):当只有一个线程访问共享数据时,使用偏向锁可以减少同步的开销。偏向锁会偏向于第一个获取锁的线程,将对象头标记为偏向锁,并将线程ID记录在对象头中。此后,该线程再次访问同步块时,无需竞争,直接获取锁。偏向锁的目标是提供低延迟的锁操作。
- 轻量级锁(Lightweight Locking):当多个线程同时访问同一块同步代码时,偏向锁会升级为轻量级锁。轻量级锁使用CAS(Compare and Swap)操作来尝试获取锁,如果成功获取锁,则继续执行同步代码块。如果获取锁失败,则表示存在竞争,升级为重量级锁。
- 重量级锁(Heavyweight Locking):当多个线程竞争同步锁时,轻量级锁会升级为重量级锁。**重量级锁使用操作系统的互斥量(Mutex)来实现,确保同一时间只有一个线程可以访问同步代码块。**当线程无法获取重量级锁时,会被阻塞挂起,直到锁被释放。
锁的升级过程是动态的,根据竞争情况和线程访问模式来进行判断和转换。如果竞争激烈,锁会很快升级为重量级锁;如果竞争较小或仅有一个线程访问,锁可能一直保持为偏向锁。锁升级的过程会带来一定的开销,因此,在设计多线程应用程序时,需要综合考虑锁的升级过程和并发性能的平衡。需要注意的是,锁的升级是由Java虚拟机自动进行的,开发人员无需显式控制。锁升级机制的目标是提供更好的并发性能和适应不同的多线程竞争场景。
更详细的见文章:无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
16.乐观锁 VS 悲观锁?公平锁 VS 非公平锁?独享锁 VS 共享锁?
更详细的见文章:乐观锁 VS 悲观锁?公平锁 VS 非公平锁?独享锁 VS 共享锁?
17.自旋锁 VS 适应性自旋锁,简单介绍
更详细的见文章:Java中常用的锁总结与理解
18.AQS理论的数据结构是什么样的?
更详细的见文章:从ReentrantLock理解AQS的原理及应用总结
AQS全称为AbstractQueuedSynchronizer,是Java中用于构建锁和同步器的框架性组件,它是Java并发包中ReentrantLock、Semaphore、ReentrantReadWriteLock等同步器的基础。AQS的设计思想是,在其内部维护了一个双向队列,用于管理请求锁的线程。当有线程请求锁时,AQS会将其封装成一个Node节点,并加入到等待队列中,线程则会进入阻塞状态。当持有锁的线程释放锁时,AQS会从等待队列中唤醒一个线程来获取锁,从而实现线程的同步和互斥。
AQS的主要特点包括:
- 支持独占模式和共享模式。独占模式下只允许一个线程持有锁,共享模式下可以允许多个线程同时持有锁。
- 内部维护了一个双向队列,用于管理请求锁的线程,队列中的节点是线程的封装。
- 通过CAS(Compare And Swap)操作实现状态的改变,状态可以是任意int类型的变量。
- 具有可重入性,即同一个线程可以多次获取同一把锁而不会出现死锁。
AQS的实现被广泛应用于Java并发包中的各种同步器,如ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch等。AQS为这些同步器提供了一个统一的基础框架,并且可以让开发人员基于此进行扩展和定制化。
AQS内部有3个对象,一个是state(用于计数器,类似gc的回收计数器),一个是线程标记(当前线程是谁加锁的),一个是阻塞队列。
它内部实现主要是状态变量state和一个FIFO队列来完成,同步队列的头结点是当前获取到同步状态的结点,获取同步状态state失败的线程,会被构造成一个结点(或共享式或独占式)加入到同步队列尾部(采用自旋CAS来保证此操作的线程安全),随后线程会阻塞;释放时唤醒头结点的后继结点,使其加入对同步状态的争夺中。
19.多线程中sleep与wait的区别是什么?
因为从表象来看,好像sleep和wait都能使线程处于阻塞状态,但是却有着本质上的区别:
- sleep是线程中的方法,但是wait是Object中的方法。
- sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。
- sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。
- sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。
20.final、finnally、finalize的区别是什么?
final,finally,finalize之间长得像但一点关系都没有,仅仅是长的像!
final 表示不可修改的,可以用来修饰类,方法,变量。
- final修饰class表示该class不可以被继承。
- inal修饰方法表示方法不可以被overrride(重写)。
- final修饰变量表示变量是不可以修改。
- 一般来说推荐将本地变量,成员变量,固定的静态变量用final修饰,明确是不可以被修改的。
finally是Java的异常处理机制中的一部分。finally块的作用就是为了保证无论出现什么情况,finally块里的代码一定会被执行。
- 一般来说在try-catch-finally 来进行类似关闭 JDBC连接,释放锁等资源的操作。
- 如果try语句块里有return语句,那么finally还会被执行吗?答案是肯定的。
finalize是Object类的一个方法,是GC进行垃圾回收前要调用的一个方法。
- 如果实现了非空的这个方法,那么会导致相应对象回收呈现数量级上的变慢,在新版的JDK中(好像是1.9之后的版本),这个方法已经逐渐被抛弃了。
21.ThreadLocal的原理和实现
了解ThreadLocal
- ThreadLocal主要用来存储当前线程上下文的变量信息,它可以保障存储进去的数据,只能被当前线程读取到,并且线程之间不会相互影响。
- ThreadLocal提供了set和get函数,set函数表示把数据存储到线程的上下文中,get函数表示从线程的上下文中读取数据。通过get函数读取数据,类似于以当前线程线程为key从map中读取数据。
- 在实际的应用场景中,InheritableThreadLocal可能更常用,它不仅可以取出当前线程存储的数据,还可以在子线程中读取父线程存储的数据。某些业务场景中,需要开启子线程,InheritableThreadLocal就派上用场了。
典型的应用场景
- 数据库事务:事务的实现原理非常简单,只需要在整个请求的处理过程中,用同一个connection开启事务、执行sql、提交事务就可以了。按照这个思路,实现起来也有两种方案:一种就是在第一次执行的时候 ,获取connection,在调用其他函数的时候,显示的传递connection对象。这种方案,只能存在于学习的demo中,无法应用到项目实践。另一种方案就是通过AOP的方式,对执行数据库事务的函数进行拦截。函数开始前,获取connection开启事务并存储在ThreadLocal中,任何用到connection的地方,从ThreadLocal中获取,函数执行完毕后,提交事务释放connection。
- web项目中的用户登录信息:web项目中,用户的登录信息通常保存在session中。按照分层的设计理念,往往会被分成controller层、service层、dao层等等,还约定在service层是不能处理request、session等对象的。一种方案是调用service函数的时候,显示的传递用户信息;另一种方案则是用到了ThreadLocal,做一个拦截器,把用户信息放在ThreadLocal中,在任何用到用户信息的时候,只需要从TreadLocal中读取就可以了。
ThreadLocal实现原理
- step1:首先看一下ThreadLocalMap,它是在ThreadLocal定义的一个内部类,看名字,就可以知道它用你来存储键值对的。只不过呢,它的Key只能是ThreadLocal对象。
- step2:再来看一下Thread,它有个ThreadLocalMap类型的属性threadLocals。
- step3:最后看一下get()函数的实现,得到当前线程的ThreadLocalMap,然后以当前的ThreadLocal对象为key,读取数据。这也就解释了为什么线程之间不会相互干扰,因为读取数据的时候,是从当前线程的ThreadLocalMap中读取的。
22.ThreadLocal为什么要使用弱引用和内存泄露问题
Map中的key为一个threadlocal实例. 这个Map的确使用了弱引用,不过弱引用只是针对key.每个key都弱引用指向threadlocal.
假如每个key都强引用指向threadlocal,那么这个threadlocal就会因为和entry存在强引用无法被回收!造成内存泄漏 ,除非线程结束,线程被回收了,map也跟着回收。
虽然上述的弱引用解决了key,也就是线程的ThreadLocal能及时被回收,但是value却依然存在内存泄漏的问题。
当把threadlocal实例置为null以后,没有任何强引用指向threadlocal实例,所以threadlocal将会被gc回收,map里面的value却没有被回收。而这块value永远不会被访问到了,所以存在着内存泄露,因为存在一条从current thread连接过来的强引用。只有当前thread结束以后,,current thread就不会存在栈中,强引用断开CurrentThreadMap,,value将全部被GC回收,所以当线程的某个localThread使用完了,马上调用threadlocal的remove方法,就不会发生这种情况了。
另外其实只要这个线程对象及时被gc回收,这个内存泄露问题影响不大,但在threadLocal设为null到线程结束中间这段时间不会被回收的,就发生了我们认为的内存泄露。最要命的是线程对象不被回收的情况,这就发生了真正意义上的内存泄露。比如使用线程池的时候,线程结束是不会销毁的,会再次使用,就可能出现内存泄露。
23.为什么要使用线程池(必考)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
Java的线程池是运用场景最多的并发框架,几乎所有需要异步或者并发执行任务的程序都可以使用线程池。
合理使用线程池能带来的好处:
- 降低资源消耗。 通过重复利用已经创建的线程降低线程创建的和销毁造成的消耗。例如,工作线程Woker会无线循环获取阻塞队列中的任务来执行。
- 提高响应速度。 当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,Java的线程池可以对线程资源进行统一分配、调优和监控。
24.线程池的线程数量确定?状态分析?关闭方式?
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
线程池的线程数量怎么确定
- 一般来说,如果是CPU密集型应用,则线程池大小设置为N+1。
- 一般来说,如果是IO密集型应用,则线程池大小设置为2N+1。
- 在IO优化中,线程等待时间所占比例越高,需要越多线程,线程CPU时间所占比例越高,需要越少线程。这样的估算公式可能更适合:最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
线程池的五种运行状态
RUNNING : 该状态的线程池既能接受新提交的任务,又能处理阻塞队列中任务。
SHUTDOWN:该状态的线程池**不能接收新提交的任务**,**但是能处理阻塞队列中的任务**。处于 RUNNING 状态时,调用 shutdown()方法会使线程池进入到该状态。
注意: finalize() 方法在执行过程中也会隐式调用shutdown()方法。
STOP: 该状态的线程池不接受新提交的任务,也不处理在阻塞队列中的任务,还会中断正在执行的任务。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法会使线程池进入到该状态;
TIDYING: 如果所有的任务都已终止,workerCount (有效线程数)=0 。线程池进入该状态后会调用 terminated() 钩子方法进入TERMINATED 状态。
TERMINATED: 在terminated()钩子方法执行完后进入该状态,默认terminated()钩子方法中什么也没有做。
线程池的关闭(shutdown或者shutdownNow方法)
可以通过调用线程池的shutdown或者shutdownNow方法来关闭线程池:遍历线程池中工作线程,逐个调用interrupt方法来中断线程。
shutdown方法与shutdownNow的特点:
- shutdown方法将线程池的状态设置为SHUTDOWN状态,只会中断空闲的工作线程。
- shutdownNow方法将线程池的状态设置为STOP状态,会中断所有工作线程,不管工作线程是否空闲。
- 调用两者中任何一种方法,都会使isShutdown方法的返回值为true;线程池中所有的任务都关闭后,isTerminated方法的返回值为true。
- 通常使用shutdown方法关闭线程池,如果不要求任务一定要执行完,则可以调用shutdownNow方法。
25.如何控制线程池线程的优先级
在Java中,线程的优先级可以通过设置线程的优先级属性来控制。线程池中的线程也可以通过设置优先级来调整其执行顺序。以下是设置线程池线程优先级的一般步骤:
创建线程池对象:首先,使用Executors类或ThreadPoolExecutor类创建一个线程池对象。
ExecutorService executor = Executors.newFixedThreadPool(10);
自定义线程工厂:通过实现ThreadFactory接口,自定义一个线程工厂类,用于创建线程对象并设置线程的优先级。
class CustomThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setPriority(Thread.MAX_PRIORITY); // 设置线程优先级
return t;
}
}
创建线程池并设置线程工厂:使用自定义的线程工厂类创建线程池对象,并将其设置为线程池的线程工厂。
ExecutorService executor = Executors
.newFixedThreadPool(10, new CustomThreadFactory());
通过以上步骤,线程池中的线程将使用自定义的线程工厂来创建,从而可以设置线程的优先级。在上述示例中,将线程优先级设置为Thread.MAX_PRIORITY,也可以根据需求设置其他优先级,如Thread.MIN_PRIORITY或Thread.NORM_PRIORITY。
需要注意的是,线程的优先级并不是绝对的,它只是给调度器一个提示,告诉它线程的相对重要性。实际的线程调度行为还受到操作系统和底层硬件的影响。因此,不能过度依赖线程的优先级来控制程序的执行顺序和性能。
此外,需要注意的是,在使用线程池时,线程的优先级可能被线程池管理器调整,以便更好地管理线程的执行顺序和资源利用。因此,在设置线程池中线程的优先级时,需要结合具体的场景和需求来评估其影响。
26.线程之间如何通信
在Java中,线程之间可以通过以下几种方式进行通信:
- 共享变量:线程之间可以通过共享变量来进行通信。多个线程可以访问和修改同一个共享变量,通过读取和修改共享变量的值来进行信息交换。需要注意的是,当多个线程同时访问共享变量时,需要保证线程安全,可以使用锁或其他同步机制来实现。
- 管道(Pipe):管道是一种半双工的通信方式,其中一个线程通过输出流将数据发送到管道,另一个线程通过输入流从管道中读取数据。管道可以用于在两个线程之间传递数据。
- 阻塞队列(Blocking Queue):阻塞队列是一种线程安全的数据结构,它提供了线程之间安全的数据交换。一个线程可以将数据放入阻塞队列的尾部,而另一个线程可以从队列的头部获取数据。当队列为空时,获取操作会被阻塞,直到有数据可用;当队列已满时,插入操作会被阻塞,直到有空间可用。
- wait/notify机制:通过调用对象的wait()方法,线程可以进入等待状态,释放对象的锁,并等待其他线程的通知。另一个线程可以通过调用对象的notify()或notifyAll()方法来唤醒等待的线程。这种方式常用于实现线程之间的协调与同步。
- Condition条件:java.util.concurrent.locks.Condition接口提供了线程间通信的高级方式。可以通过Condition对象与锁(例如ReentrantLock)结合使用,实现更灵活的线程间通信。线程可以通过调用await()方法进入等待状态,通过调用signal()或signalAll()方法来唤醒等待的线程。
这些是常见的线程间通信方式,具体的选择取决于场景和需求。需要根据具体情况选择合适的通信方式,并使用正确的同步机制来保证线程间的安全性和可靠性。
27.核心线程池ThreadPoolExecutor的参数(必考)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
可以通过ThreadPoolExecutor
来创建一个线程池,先上代码吧:
new ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler)
常用的5个,核心池、最大池、空闲时间、时间的单位、阻塞队列;另外两个:拒绝策略、线程工厂类
- corePoolSize:指定了线程池中的线程数量
- maximumPoolSize:指定了线程池中的最大线程数量
- keepAliveTime:线程池维护线程所允许的空闲时间
- unit: keepAliveTime 的单位。
- workQueue:任务队列,被提交但尚未被执行的任务。
- threadFactory:线程工厂,用于创建线程,一般用默认的即可。
- handler:拒绝策略。当任务太多来不及处理,如何拒绝任务。
具体详细说明:
corePoolSize(线程池的基本大小):
- 提交一个任务到线程池时,线程池会创建一个新的线程来执行任务。注意: 即使有空闲的基本线程能执行该任务,也会创建新的线程。
- 如果线程池中的线程数已经大于或等于corePoolSize,则不会创建新的线程。
- 如果调用了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有基本线程。
maximumPoolSize(线程池的最大数量): 线程池允许创建的最大线程数。
- 阻塞队列已满,线程数小于maximumPoolSize便可以创建新的线程执行任务。
- 如果使用无界的阻塞队列,该参数没有什么效果。
workQueue(工作队列): 用于保存等待执行的任务的阻塞队列。
- ArrayBlockingQueue: 基于数组结构的有界阻塞队列,按FIFO(先进先出)原则对任务进行排序。使用该队列,线程池中能创建的最大线程数为maximumPoolSize。
- LinkedBlockingQueue: 基于链表结构的有界阻塞队列,按FIFO(先进先出)原则对任务进行排序,吞吐量高于ArrayBlockingQueue。使用该队列,线程池中能创建的最大线程数为corePoolSize。静态工厂方法 Executor.newFixedThreadPool()使用了这个队列。
- SynchronousQueue: 一个不存储元素的阻塞队列。添加任务的操作必须等到另一个线程的移除操作,否则添加操作一直处于阻塞状态。静态工厂方法 Executor.newCachedThreadPool()使用了这个队列。
- PriorityBlokingQueue: 一个支持优先级的无界阻塞队列。使用该队列,线程池中能创建的最大线程数为corePoolSize。
keepAliveTime(线程活动保持时间): 线程池的工作线程空闲后,保持存活的时间。如果任务多而且任务的执行时间比较短,可以调大keepAliveTime,提高线程的利用率。
unit(线程活动保持时间的单位): 可选单位有DAYS、HOURS、MINUTES、毫秒、微秒、纳秒。
handler(饱和策略,或者又称拒绝策略): 当队列和线程池都满了,即线程池饱和了,必须采取一种策略处理提交的新任务。
- AbortPolicy: 无法处理新任务时,直接抛出异常,这是默认策略。
- CallerRunsPolicy:用调用者所在的线程来执行任务。
- DiscardOldestPolicy:丢弃阻塞队列中最靠前的一个任务,并执行当前任务。
- DiscardPolicy: 直接丢弃任务。
threadFactory: 构建线程的工厂类
28.常见线程池的创建参数是什么样的?
PS: CachedThreadPool
核心池为0,最大池为Integer.MAX_VALUE
,相当于只使用了最大池;其他线程池,核心池与最大池一样大,因此相当于只用了核心池。
FixedThredPool: new ThreadExcutor(n, n, 0L, ms, new LinkedBlockingQueue<Runable>()
SingleThreadExecutor: new ThreadExcutor(1, 1, 0L, ms, new LinkedBlockingQueue<Runable>())
CachedTheadPool: new ThreadExcutor(0, max_valuem, 60L, s, new SynchronousQueue<Runnable>());
ScheduledThreadPoolExcutor: ScheduledThreadPool, SingleThreadScheduledExecutor.
注意:
- 如果使用的阻塞队列为无界队列,则永远不会调用拒绝策略,因为再多的任务都可以放在队列中。
SynchronousQueue
是不存储任务的,新的任务要么立即被已有线程执行,要么创建新的线程执行。
29.ThreadPoolExecutor的工作流程(必考)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
基本背景思路:
一个新的任务到线程池时,线程池的处理流程如下:
- 线程池判断核心线程池里的线程是否都在执行任务。 如果不是,创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
- 线程池判断阻塞队列是否已满。 如果阻塞队列没有满,则将新提交的任务存储在阻塞队列中。如果阻塞队列已满,则进入下个流程。
- 线程池判断线程池里的线程是否都处于工作状态。 如果没有,则创建一个新的工作线程来执行任务。如果已满,则交给饱和策略来处理这个任务。
ThreadPoolExecutor类
具体的处理流程:
线程池的核心实现类是ThreadPoolExecutor类
,用来执行提交的任务。因此,任务提交到线程池时,具体的处理流程是由ThreadPoolExecutor类
的execute()方法去完成的。
- 如果当前运行的线程少于corePoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
- 如果当前运行的线程大于或等于corePoolSize,而且BlockingQueue未满,则将任务加入到BlockingQueue中。
- 如果BlockingQueue已满,而且当前运行的线程小于maximumPoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
- 如果当前运行的线程大于或等于maximumPoolSize,任务将被拒绝,并调用RejectExecutionHandler.rejectExecution()方法。即调用饱和策略对任务进行处理。
30.Java线程池的调优经验有哪些?(线程池的合理配置)
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
这里直接推荐使用动态线程池配置和监控更加符合业务要求,具体见上述博客!
从以下几个角度分析任务的特性:
- 任务的性质:
CPU 密集型任务
、IO 密集型任务
和混合型任务
。 - 任务的优先级: 高、中、低。
- 任务的执行时间: 长、中、短。
- 任务的依赖性:
是否依赖其他系统资源
,如数据库连接
。
任务性质不同的任务可以用不同规模的线程池分开处理。 可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数。
- CPU 密集型任务配置尽可能小的线程,如配置
个线程的线程池。
- IO 密集型任务则由于线程并不是一直在执行任务,则配置尽可能多的线程,如
。
- 混合型任务,如果可以拆分,则将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务。只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率;如果这两个任务执行时间相差太大,则没必要进行分解。
优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理,它可以让优先级高的任务先得到执行。但是,如果一直有高优先级的任务加入到阻塞队列中,那么低优先级的任务可能永远不能执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,线程数应该设置得较大,这样才能更好的利用 CPU。
建议使用有界队列,有界队列能增加系统的稳定性和预警能力。可以根据需要设大一点,比如几千。使用无界队列,线程池的队列就会越来越大,有可能会撑满内存,导致整个系统不可用。
怎么对线程池进行有效监控?
以通过线程池提供的参数读线程池进行监控,有以下属性可以使用:
- taskCount:线程池需要执行的任务数量,包括已经执行完的、未执行的和正在执行的。
- completedTaskCount:线程池在运行过程中已完成的任务数量,completedTaskCount <= taskCount。
- largestPoolSize:线程池曾经创建过的最大线程数量,通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
- getPoolSize: 线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以线程池的线程数量只增不减。
- getActiveCount:获取活动的线程数。
通过继承线程池并重写线程池的 beforeExecute,afterExecute 和 terminated 方法,我们可以在任务执行前,执行后和线程池关闭前干一些事情。
30.Boolean占几个字节
未精确定义字节。
首先在Java中定义的八种基本数据类型中,除了其它七种类型都有明确的内存占用字节数外,就boolean类型没有给出具体的占用字节数,因为对虚拟机来说根本就不存在 boolean 这个类型,boolean类型在编译后会使用其他数据类型来表示。
boolean类型没有给出精确的定义,《Java虚拟机规范》给出了4个字节,和boolean数组1个字节的定义,具体还要看虚拟机实现是否按照规范来,所以1个字节、4个字节都是有可能的。这其实是运算效率和存储空间之间的博弈,两者都非常的重要。
31.Exception和Error
- Exception和Error都是继承了Throwable类,在java中只有Throwable类型的实例才可以被抛出(throw)或者捕获(catch),他是异常处理机制的基本组成类型。
- Exception和Error体现了java平台设计者对不同异常情况的分类,Exception是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应的处理。
- Error是指正常情况下,不大可能出现的情况,绝大部分的Error都会导致程序(比如JVM自身)处于非正常状态,不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如OutOfMemoryError之类,都是Error的子类。
- Exception又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源码里必须显示的进行捕获处理,这里是编译期检查的一部分。前面我们介绍的不可查的Error,是Throwable不是Exception。
- 不检查异常就是所谓的运行时异常,类似NullPointerException,ArrayIndexOutOfBoundsExceptin之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译器强制要求。
32.Object类内的方法
Object是所有类的父类,任何类都默认继承Object。Object类到底实现了哪些方法?
clone方法:保护方法,实现对象的浅复制,只有实现了Cloneable接口才可以调用该方法,否则抛出CloneNotSupportedException异常。
getClass方法:final方法,获得运行时类型。
toString方法:该方法用得比较多,一般子类都有覆盖。
finalize方法:该方法用于释放资源。因为无法确定该方法什么时候被调用,很少使用。
equals方法:该方法是非常重要的一个方法。一般equals和==是不一样的,但是在Object中两者是一样的。子类一般都要重写这个方法。
hashCode方法:该方法用于哈希查找,重写了equals方法一般都要重写hashCode方法。这个方法在一些具有哈希功能的Collection中用到。一般必须满足obj1.equals(obj2)==true。可以推出obj1.hashCode()==obj2.hashCode(),但是hashCode相等不一定就满足equals。不过为了提高效率,应该尽量使上面两个条件接近等价。
wait方法:wait方法就是使当前线程等待该对象的锁,当前线程必须是该对象的拥有者,也就是具有该对象的锁。wait()方法一直等待,直到获得锁或者被中断。wait(long timeout)设定一个超时间隔,如果在规定时间内没有获得锁就返回。
调用该方法后当前线程进入睡眠状态,直到以下事件发生。
(1)其他线程调用了该对象的notify方法。
(2)其他线程调用了该对象的notifyAll方法。
(3)其他线程调用了interrupt中断该线程。
(4)时间间隔到了。
此时该线程就可以被调度了,如果是被中断的话就抛出一个InterruptedException异常。
notify方法:该方法唤醒在该对象上等待的某个线程。
notifyAll方法:该方法唤醒在该对象上等待的所有线程。
33.Jdk1.8/Jdk1.7都分别新增了哪些特性?其他版本呢?
Java 8新增特性:
- Lambda表达式:引入了函数式编程的概念,使得代码更简洁、可读性更高。
- Stream API:提供了一种更便利的处理集合数据的方式,支持并行处理。
- 默认方法(Default Methods):接口中可以定义默认实现,允许在接口中添加新方法而不破坏现有实现类的兼容性。
- 方法引用(Method References):可以通过方法的名字来引用已存在的方法。
- Optional类:提供了一种更好的处理可能为null的对象的方式,避免了空指针异常。
- 新的日期/时间API(java.time包):提供了更好的日期和时间处理方式,解决了旧的日期API的一些问题。
- CompletableFuture类:新增的异步编程工具,支持更方便地处理异步任务和回调。
Java 7新增特性:
- switch语句支持字符串类型:可以在switch语句中使用字符串进行比较。
- 泛型实例化类型自动推断:在创建泛型对象时,可以省略泛型类型的重复声明。
- try-with-resources语句:用于自动关闭实现了AutoCloseable接口的资源,避免了手动关闭资源的繁琐操作。
- 改进的类型推断:在实例化泛型对象时,编译器可以根据上下文推断出泛型的类型。
- 数字字面量下划线支持:可以在数字字面量中使用下划线分隔以提高可读性。
除了Java 8和Java 7,其他版本的Java也都引入了一些新特性和改进,其中一些主要的特性包括:
Java 9:
- 模块化系统(Project Jigsaw)
- JShell交互式解释器
- Reactive Streams API
- 改进的Stream API
- 私有接口方法
- 改进的垃圾收集器
Java 10:
- 局部变量类型推断
- 基于时间的版本号(Release Versioning)
- 并行全垃圾回收器
Java 11:
- HTTP Client API
- 局部变量语法增强
- ZGC垃圾回收器
- Epsilon垃圾收集器
Java 12:
- Switch表达式增强
- 新的垃圾收集器(Shenandoah)
- Microbenchmark Suite
Java 13:
- 文本块(Text Blocks)
- 动态CDS归档(Dynamic CDS Archives)
- ZGC并发压缩
Java 14:
- Switch表达式增强
- Pattern匹配
- 垃圾回收器改进
- Records(记录类)
Java 15:
- Sealed Classes(密封类)
- Text Blocks改进
- 垃圾回收器改进
- 隐藏类
Java 16:
- 隐藏类(Hidden Classes)
- Pattern匹配
- Records改进
- 新的垃圾回收器(ZGC并发压缩)
- UNIX套接字通道的改进
- 可见注释
Java 17:
- Sealed Classes(密封类)改进
- Pattern匹配增强
- 垃圾回收器改进
- 向后兼容性保持
- 升级Elasticsearch版本
Java 18(计划中):
- 目前尚未发布,具体特性尚未确定。
这些是Java 9到18版本的一些重要新增特性和改进。请注意,每个版本可能还包含了其他小的改进、修复和性能优化。建议参考官方文档和相关资源以获取更详细和全面的信息。
34.StringBuffer和StringBuilder的区别是什么?性能对比?如何鉴定线程安全?
基本对比:
- String:String对象是不可变的。“对String对象的任何改变都不影响到原对象,相关的任何change操作都会生成新的对象”。
- StringBuilder:StringBuilder是可变的,它不是线程安全的。
- StringBuffer:StringBuffer也是可变的,它是线程安全的,所以它的开销比StringBuilder大
使用时的建议:
- 循环外字符串拼接可以直接使用String的+操作,没有必要通过StringBuilder进行append.
- 有循环体的话,好的做法是在循环外声明StringBuilder对象,在循环内进行手动append。不论循环多少层都只有一个StringBuilder对象。
- 当字符串相加操作较多的情况下,建议使用StringBuilder,如果采用了多线程,则使用StringBuffer。
如何鉴定线程安全:
查看源代码便一目了然,事实上,StringBuilder和StringBuffer类拥有的成员属性以及成员方法基本相同,区别是StringBuffer类的成员方法前面多了一个关键字:synchronized,不用多说,这个关键字是在多线程访问时起到安全保护作用的,也就是说StringBuffer是线程安全的。
35.String str="hello world"和String str=new String(“hello world”)的区别?
String str=“hello world”
通过直接赋值的形式可能创建一个或者不创建对象,如果"hello world"在字符串池中不存在,会在java字符串池中创建一个String对象(“hello world”),常量池中的值不能有重复的,所以当你通过这种方式创建对象的时候,java虚拟机会自动的在常量池中搜索有没有这个值,如果有的话就直接利用他的值,如果没有,他会自动创建一个对象,所以,str指向这个内存地址,无论以后用这种方式创建多少个值为”hello world”的字符串对象,始终只有一个内存地址被分配。
String str=new String(“hello world”)
通过new 关键字至少会创建一个对象,也有可能创建两个。
因为用到new关键字,肯定会在堆中创建一个String对象,如果字符池中已经存在"hello world",则不会在字符串池中创建一个String对象,如果不存在,则会在字符串常量池中也创建一个对象。他是放到堆内存中的,这里面可以有重复的,所以每一次创建都会new一个新的对象,所以他们的地址不同。
String 有一个intern() 方法,native,用来检测在String pool是否已经有这个String存在。
36.Array和ArrayList有什么区别?使用时注意事项有哪些?
Array和ArrayList是Java中用于存储和操作多个元素的数据结构,它们有一些区别和使用时需要注意的事项。
固定大小 vs 动态大小:
- Array(数组)具有固定的大小,一旦创建后大小不可变。
- ArrayList是基于数组实现的动态大小的容器,可以根据需要自动调整大小。
类型限制:
- Array可以存储任何类型的元素,包括基本类型和引用类型。
- ArrayList只能存储引用类型的元素,不能直接存储基本类型,需要使用其对应的包装类。
增删元素:
- Array的大小固定,无法直接增加或删除元素。可以通过创建新的Array并复制元素来模拟增删操作。
- ArrayList提供了方便的方法来添加(add())和删除(remove())元素,可以动态调整大小。
遍历:
- Array可以使用简单的for循环或增强for循环遍历。
- ArrayList同样可以使用for循环或增强for循环遍历,也可以使用迭代器(Iterator)进行遍历。
性能:
- Array的访问速度较快,因为元素在内存中是连续存储的。
- ArrayList的访问速度相对较慢,因为需要通过索引计算元素位置。
注意事项:
- Array在创建时需要指定大小,并且大小不能改变,如果需要动态调整大小,需要手动操作。
- ArrayList在使用时可以根据需要动态调整大小,无需手动处理大小问题。
- 使用Array时,需要手动处理增删元素和数组大小的维护。
- ArrayList是线程不安全的,如果在多线程环境下使用,需要进行适当的同步或使用线程安全的替代类(如Vector、CopyOnWriteArrayList等)。
- Array可以直接存储基本类型的元素,而ArrayList需要使用对应的包装类作为元素类型。
- Array在创建时需要明确指定元素类型和大小,而ArrayList在创建时无需指定大小,可以根据需要动态扩展。
根据具体的需求和场景,选择适合的数据结构。如果需要灵活的大小调整和内置操作方法,可以使用ArrayList;如果需要更高的性能和直接的内存访问,可以使用Array。
37.LRU算法是怎么实现的?大致说明下(必考)
LRU算法的设计原则:
如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。
实现LRU思路:
第一种方法:利用数组来实现
用一个数组来存储数据,给每一个数据项标记一个访问时间戳
每次插入新数据项的时候,先把数组中存在的数据项的时间戳自增,并将新数据项的时间戳置为0并插入到数组中
每次访问数组中的数据项的时候,将被访问的数据项的时间戳置为0。
当数组空间已满时,将时间戳最大的数据项淘汰。
第二种方法:利用链表来实现
每次新插入数据的时候将新数据插到链表的头部
每次缓存命中(即数据被访问),则将数据移到链表头部;
那么当链表满的时候,就将链表尾部的数据丢弃。
第三种方法:利用链表和hashmap来实现
当需要插入新的数据项的时候,如果新数据项在链表中存在(一般称为命中),则把该节点移到链表头部,如果不存在,则新建一个节点,放到链表头部,若缓存满了,则把链表最后一个节点删除即可。
在访问数据的时候,如果数据项在链表中存在,则把该节点移到链表头部,否则返回-1。这样一来在链表尾部的节点就是最近最久未访问的数据项。
对于第一种方法,需要不停地维护数据项的访问时间戳,另外,在插入数据、删除数据以及访问数据时,时间复杂度都是O(n)。对于第二种方法,链表在定位数据的时候时间复杂度为O(n)。所以在一般使用第三种方式来是实现LRU算法。
具体实现方案:使用LinkedHashMap实现
LinkedHashMap底层就是用的HashMap加双链表实现的,而且本身已经实现了按照访问顺序的存储。
此外,LinkedHashMap中本身就实现了一个方法removeEldestEntry用于判断是否需要移除最不常读取的数,方法默认是直接返回false,不会移除元素,所以需要重写该方法。即当缓存满后就移除最不常用的数。
public class LRU<K,V> {
private static final float hashLoadFactory = 0.75f;
private LinkedHashMap<K,V> map;
private int cacheSize;
public LRU(int cacheSize) {
this.cacheSize = cacheSize;
int capacity = (int)Math.ceil(cacheSize / hashLoadFactory) + 1;
map = new LinkedHashMap<K,V>(capacity, hashLoadFactory, true){
private static final long serialVersionUID = 1;
/*将LinkedHashMap中的removeEldestEntry进行重写改造*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > LRU.this.cacheSize;
}
};
}
public synchronized V get(K key) {
return map.get(key);
}
public synchronized void put(K key, V value) {
map.put(key, value);
}
public synchronized void clear() {
map.clear();
}
public synchronized int usedSize() {
return map.size();
}
public void print() {
for (Map.Entry<K, V> entry : map.entrySet()) {
System.out.print(entry.getValue() + "--");
}
System.out.println();
}
}
自编代码:基于 HashMap 和 双向链表实现 LRU
基本代码见:LRU缓存机制(LRU Cache)
整体的设计思路是:可以使用 HashMap 存储 key,这样可以做到 put 和 get key的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。
LRU 存储是基于双向链表实现的,下面的图演示了它的原理。其中 h 代表双向链表的表头,t 代表尾部。首先预先设置 LRU 的容量,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
总结一下核心操作的步骤:
- put(key, value),首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。如果不存在,需要构造新的节点,并且尝试把节点塞到队头,如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。
- get(key),通过 HashMap 找到 LRU 链表节点,把节点插入到队头,返回缓存的值。
定义基本结构:
class DLinkedNode {
String key;
int value;
DLinkedNode pre;
DLinkedNode post;
}
具体手写代码如下:
package org.zyf.javabasic.letcode.hash;
import java.util.HashMap;
import java.util.Map;
/**
* @author yanfengzhang
* @description 设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作:获取数据 get 和写入数据 put 。
* 获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。
* 写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,
* 它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。
* <p>
* 进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?
* @date 2023/4/9 19:11
*/
public class LRUCache {
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
}
private Map<Integer, DLinkedNode> cache = new HashMap<>();
private int size;
private int capacity;
private DLinkedNode head, tail;
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) {
return -1;
}
/*将节点移动到双向链表头部*/
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node == null) {
/*如果节点不存在,则创建一个新节点并加入到双向链表头部和哈希表中*/
DLinkedNode newNode = new DLinkedNode();
newNode.key = key;
newNode.value = value;
cache.put(key, newNode);
addToHead(newNode);
size++;
if (size > capacity) {
/*如果超出容量,则删除双向链表尾部节点并在哈希表中删除对应的键值对*/
DLinkedNode tail = removeTail();
cache.remove(tail.key);
size--;
}
} else {
/*如果节点存在,则更新节点的值,并将节点移动到双向链表头部*/
node.value = value;
moveToHead(node);
}
}
private void addToHead(DLinkedNode node) {
/*将节点加入到双向链表头部*/
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedNode node) {
/*从双向链表中删除节点*/
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(DLinkedNode node) {
/*将节点移动到双向链表头部*/
removeNode(node);
addToHead(node);
}
private DLinkedNode removeTail() {
/*删除双向链表尾部节点,并返回被删除的节点*/
DLinkedNode tail = this.tail.prev;
removeNode(tail);
return tail;
}
/**
* 可以看到,LRU 缓存机制在存储容量达到最大值时,
* 能够正确地淘汰最近最少使用的节点,
* 并保证每个节点的访问顺序符合 LRU 缓存机制的要求。
*/
public static void main(String[] args) {
LRUCache cache = new LRUCache(2);
cache.put(1, 1);
cache.put(2, 2);
/*output: 1*/
System.out.println(cache.get(1));
cache.put(3, 3);
/*output: -1*/
System.out.println(cache.get(2));
cache.put(4, 4);
/*output: -1*/
System.out.println(cache.get(1));
/*output: 3*/
System.out.println(cache.get(3));
/*output: 4*/
System.out.println(cache.get(4));
}
}
其他相关内容补充:LRU-K
LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
- 数据第一次被访问时,加入到历史访问列表,如果数据在访问历史列表中没有达到K次访问,则按照一定的规则(FIFO,LRU)淘汰;
- 当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列中删除,将数据移到缓存队列中,并缓存数据,缓存队列重新按照时间排序;
- 缓存数据队列中被再次访问后,重新排序,需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即“淘汰倒数K次访问离现在最久的数据”。
LRU-K具有LRU的优点,同时还能避免LRU的缺点,实际应用中LRU-2是综合最优的选择。由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多。
其他相关内容补充:two queue
Two queues(以下使用2Q代替)算法类似于LRU-2,不同点在于2Q将LRU-2算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。
- 当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。
- 新访问的数据插入到FIFO队列中,如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;
- 如果数据在FIFO队列中再次被访问到,则将数据移到LRU队列头部,如果数据在LRU队列中再次被访问,则将数据移动LRU队列头部,LRU队列淘汰末尾的数据。
其他相关内容补充:Multi Queue(MQ)
MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,其核心思想是:优先缓存访问次数多的数据。Q0,Q1…Qk代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:
- 新插入的数据放入Q0,每个队列按照LRU进行管理,当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列中删除,加入到高一级队列的头部;
- 为了防止高优先级数据永远不会被淘汰,当数据在指定的时间里没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;
- 需要淘汰数据时,从最低一级队列开始按照LRU淘汰,每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部。如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列头部。Q-history按照LRU淘汰数据的索引。
MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。
38.CAS?CAS 有什么缺陷,如何解决?(必考)
更详细的见文章:CAS技术分析
CAS(Compare and Swap)是一种并发编程中的原子操作,用于实现多线程环境下的无锁同步。它基于比较当前值与期望值的方式来更新变量的值,只有在当前值与期望值相等的情况下才进行更新,否则不进行更新。
CAS的主要缺陷是ABA问题。ABA问题指的是,在执行CAS操作期间,变量的值经过一系列的修改先变成了A,然后又被修改为B,最后又被修改回A。在这种情况下,CAS操作会错误地认为变量的值没有被其他线程修改过,导致操作成功,但实际上变量的值已经发生了变化。
为了解决ABA问题,可以采取以下两种方法:
- 版本号或标记:在变量值的基础上增加一个版本号或标记,每次修改时都更新版本号或标记。这样,即使变量的值从A变为B再变回A,由于版本号或标记的变化,CAS操作会正确地判断变量是否被修改过。
- 带有回退的CAS:在执行CAS操作时,除了比较当前值与期望值外,还比较变量的修改历史。如果发现变量的值在修改期间发生了变化,即使当前值与期望值相等,CAS操作也会失败,需要重新尝试。
Java中的Atomic类提供了基于CAS操作的原子类,如AtomicInteger、AtomicLong等。这些原子类已经内部处理了ABA问题,使用了类似版本号或标记的机制来解决ABA问题,从而提供了线程安全的原子操作。
需要注意的是,尽管CAS是一种无锁的同步机制,但在高并发场景下,由于CAS操作可能会多次失败和重试,从而导致性能下降。因此,在选择使用CAS时,需要根据具体场景综合考虑其性能和实现复杂度。
39.ScheduledThreadPoolExecutor中的使用的是什么队列?内部如何实现任务排序的?
ScheduledThreadPoolExecutor继承自ThreadPoolExecutor。
它主要用来在给定的延迟之后运行任务,或者定期执行任务。ScheduledThreadPoolExecutor的功能与Timer类似,但 ScheduledThreadPoolExecutor功能更强大、更灵活。Timer对应的是单个后台线程,而 ScheduledThreadPoolExecutor可以在构造函数中指定多个对应的后台线程数。
DelayQueue是一个无界队列,所以ThreadPoolExecutor的maximumPoolSize在ScheduledThreadPoolExecutor中没有什么意义(设置maximumPoolSize的大小没有什么效果)。
ScheduledThreadPoolExecutor的执行主要分为两大部分。
- 1)当调用ScheduledThreadPoolExecutor的scheduleAtFixedRate()方法或者scheduleWithFixedDelay()方法时,会向ScheduledThreadPoolExecutor的DelayQueue添加一个实现了RunnableScheduledFutur接口的ScheduledFutureTask。
- 2)线程池中的线程从DelayQueue中获取ScheduledFutureTask,然后执行任务。
ScheduledThreadPoolExecutor为了实现周期性的执行任务,对ThreadPoolExecutor做了如下的修改。
- 使用DelayQueue作为任务队列。
- 获取任务的方式不同。
- 执行周期任务后,增加了额外的处理。
40.Future原理,其局限性是什么?并说说CompletableFuture核心原理?
更详细的见文章:CompletableFuture回调机制的设计与实现
Future的实现原理就是通过Future和FutureTask接口,将任务封装成一个异步操作,并在主线程中等待任务完成后获取执行结果。FutureTask是Future的一个具体实现,通过阻塞方法和回调函数来实现异步操作的结果获取。
虽然Future在Java中提供了一种简单的异步编程技术,但它也存在一些局限性,包括以下几个方面:
- 阻塞问题:Future的get()方法是一个阻塞方法,如果任务没有完成,会一直阻塞当前线程,这会导致整个应用程序的响应性下降。
- 无法取消任务:Future的cancel()方法可以用于取消任务的执行,但如果任务已经开始执行,则无法取消。此时只能等待任务执行完毕,这会导致一定的性能损失。
- 缺少异常处理:Future的get()方法会抛出异常,但是如果任务执行过程中抛出异常,Future无法处理异常,只能将异常抛给调用者处理。
- 缺少组合操作:Future只能处理单个异步操作,无法支持多个操作的组合,例如需要等待多个任务全部完成后再执行下一步操作。
综上所述,Future虽然提供了一种简单的异步编程技术,但它的局限性也是比较明显的。在实际应用中,我们需要根据具体的业务需求和性能要求,选择合适的异步编程技术。例如,可以使用CompletableFuture来解决Future的一些问题,它可以避免阻塞、支持异常处理和组合操作等功能。
CompletableFuture原理总述与回调机制总结
CompletableFuture是Java 8中引入的一个强大的异步编程工具,它允许我们以非阻塞的方式处理异步操作,并通过回调函数来处理异步操作完成后的结果。
**CompletableFuture的核心原理是基于Java的Future接口和内部的状态机实现的。**它可以通过三个步骤来实现异步操作:
- 创建CompletableFuture对象:通过CompletableFuture的静态工厂方法,我们可以创建一个新的CompletableFuture对象,并指定该对象的异步操作。通常情况下,我们可以通过supplyAsync()或者runAsync()方法来创建CompletableFuture对象。
- 异步操作的执行:在CompletableFuture对象创建之后,异步操作就开始执行了。这个异步操作可以是一个计算任务或者一个IO操作。CompletableFuture会在另一个线程中执行这个异步操作,这样主线程就不会被阻塞。
- 对异步操作的处理:异步操作执行完成后,CompletableFuture会根据执行结果修改其内部的状态,并触发相应的回调函数。如果异步操作成功完成,则会触发CompletableFuture的完成回调函数;如果异步操作抛出异常,则会触发CompletableFuture的异常回调函数。
CompletableFuture的优势在于它支持链式调用和组合操作。通过CompletableFuture的then系列方法,我们可以创建多个CompletableFuture对象,并将它们串联起来形成一个链式的操作流。在这个操作流中,每个CompletableFuture对象都可以依赖于之前的CompletableFuture对象,以实现更加复杂的异步操作。
总的来说,CompletableFuture的原理是基于Java的Future接口和内部的状态机实现的,它可以以非阻塞的方式执行异步操作,并通过回调函数来处理异步操作完成后的结果。通过链式调用和组合操作,CompletableFuture可以方便地实现复杂的异步编程任务。
41.伪共享机制简述分析
伪共享(False Sharing)是一种硬件和软件交互的现象,它可能对多线程程序的性能产生负面影响。
下面是对伪共享机制的简要分析:
**伪共享通常发生在多个线程同时访问不同但位于同一缓存行(Cache Line)的数据时。**缓存行是计算机内存中缓存的最小单位,通常是64字节。当多个线程同时修改或读取不同的数据,但这些数据位于同一缓存行时,就会引发伪共享问题。
当一个线程修改缓存行中的某个数据时,该缓存行会被标记为”脏”,并且会将整个缓存行的数据刷新到主内存中。这将导致其他线程对于同一缓存行中的数据的缓存失效,即其他线程需要从主内存重新加载该缓存行的数据。这种缓存失效的频繁发生会导致性能下降。
伪共享问题的解决方案之一是通过对共享的数据进行填充(Padding),使得不同线程访问的数据分散到不同的缓存行上,从而避免了不必要的缓存行失效。填充可以通过在数据结构中添加额外的空间或使用特定的对齐方式来实现。
另一种解决伪共享问题的方法是使用缓存行对齐(Cache Line Alignment)技术。这种技术通过将数据结构的每个成员对齐到缓存行的边界,确保不同线程访问的数据位于不同的缓存行中,减少了缓存行的失效次数。
总而言之,伪共享是由于多个线程同时访问同一缓存行中不同数据而导致的性能问题。通过填充和缓存行对齐等技术,可以减少伪共享对多线程程序性能的影响,提高系统的并发性能。
42.线程池的运行逻辑,FixedThreadPool、CachedThreadPool的原理(必考)
线程池是一种用于管理和调度线程的机制,它可以有效地管理线程的创建、执行和销毁。下面是线程池的运行逻辑以及FixedThreadPool和CachedThreadPool的原理:
线程池的运行逻辑:
1. 初始化线程池,设置线程池的核心线程数、最大线程数、任务队列等参数。
2. 当有任务提交给线程池时,线程池会按照以下规则执行:
a. 如果当前运行的线程数小于核心线程数,创建新的线程来执行任务。
b. 如果当前运行的线程数等于核心线程数,将任务放入任务队列等待执行。
c. 如果任务队列已满且当前运行的线程数小于最大线程数,创建新的线程来执行任务。
d. 如果任务队列已满且当前运行的线程数达到最大线程数,根据线程池的拒绝策略来处理任务。
3. 当线程执行完一个任务后,它会从任务队列中获取下一个任务进行执行,直到线程池关闭或出现异常。
4. 如果线程池闲置一段时间(根据具体实现而定),超过预设的存活时间,额外的线程会被终止,以节省资源。
5. 线程池可以通过调整核心线程数、最大线程数和任务队列等参数进行灵活的配置和优化。
FixedThreadPool的原理:
FixedThreadPool是一种固定大小的线程池,它会在初始化时创建指定数量的线程,并且线程数不会改变。它的原理是:
1. 初始化FixedThreadPool时,创建指定数量的线程,即核心线程数和最大线程数都是固定的。
2. 当有任务提交给FixedThreadPool时,如果有空闲线程,则立即分配线程来执行任务。
3. 如果所有线程都在执行任务且任务队列已满,则新任务将被阻塞,直到有线程空闲或任务队列有空闲位置。
4. 因为FixedThreadPool的线程数是固定的,所以它适合于需要固定线程数的场景,例如需要控制资源消耗或并发度的应用。
CachedThreadPool的原理:
CachedThreadPool是一种根据需要自动调整线程数量的线程池,它的原理是:
1. 初始化CachedThreadPool时,不会创建任何线程。
2. 当有任务提交给CachedThreadPool时,它会尝试重用之前空闲的线程,如果有可用的空闲线程,则立即分配线程来执行任务。
3. 如果所有线程都在执行任务且任务队列已满,则会创建新的线程来处理新的任务。
4. 如果某个线程在一段时间内没有任务可执行,它将被终止并从线程池中移除,以节省资源。
5. 当新的任务提交给CachedThreadPool时,如果之前终止的线程数量不超过最大线程数,那么会重新使用之前终止的线程来执行任务,而不是创建新的线程。
6. CachedThreadPool会根据任务的数量和执行时间的情况自动调整线程数量,增加线程以处理更多的任务,减少线程以释放闲置的资源。
7. CachedThreadPool适用于任务量不固定、需要快速响应并且执行时间较短的场景,可以根据需求动态调整线程数量,以提高线程的利用率和系统的响应能力。
总而言之,FixedThreadPool和CachedThreadPool是两种常见的线程池实现。FixedThreadPool适用于需要固定线程数的场景,而CachedThreadPool适用于任务量不确定的场景,它会根据需求动态调整线程数量以提高系统的性能。
43.阻塞队列ArrayBlockingQueue、LinkedBlockingQueue分析
ArrayBlockingQueue和LinkedBlockingQueue都是Java中常见的阻塞队列实现,它们都提供了线程安全的队列操作,并且支持在队列为空或已满时的阻塞操作。
ArrayBlockingQueue
- ArrayBlockingQueue是一个基于数组的有界阻塞队列,它的容量在创建时被固定。
- 内部使用一个固定大小的数组来存储元素,因此在添加或移除元素时具有较高的效率。
- 当尝试将元素添加到已满的队列中时,操作将被阻塞,直到队列有空闲位置可用。
- 当尝试从空队列中移除元素时,操作也将被阻塞,直到队列中有元素可供移除。
- ArrayBlockingQueue的阻塞操作是通过使用内置的锁和条件变量实现的。
LinkedBlockingQueue
- LinkedBlockingQueue是一个基于链表的可选界限阻塞队列,它可以选择在创建时设置容量上限,如果未指定容量,则默认为无界队列。
- 内部使用链表来存储元素,因此可以动态调整大小,并且没有固定容量的限制。
- 当尝试将元素添加到已满的有界队列中时,操作将被阻塞,直到队列有空闲位置可用。
- 当尝试从空队列中移除元素时,操作也将被阻塞,直到队列中有元素可供移除。
- LinkedBlockingQueue的阻塞操作是通过使用内置的锁和条件变量实现的。
两者的选择
- 如果你需要一个有界队列,并且在队列已满时阻塞添加操作或队列为空时阻塞移除操作,可以选择ArrayBlockingQueue。
- 如果你需要一个可以动态调整大小的队列,或者希望使用默认的无界队列,可以选择LinkedBlockingQueue。
需要注意的是,无界队列可能会在持续添加元素时耗尽系统的内存资源,因此在选择队列实现时要根据场景和需求进行权衡和选择。
44.epoll、select、poll原理
**epoll、select和poll是常用的I/O多路复用机制,用于处理并发的I/O操作。**它们在不同的操作系统上实现了类似的功能,允许程序同时监视多个文件描述符(sockets、文件等),并在其中任意一个文件描述符就绪时进行相应的操作。
select
select是最早出现的I/O多路复用函数之一。它通过将待监视的文件描述符按位图方式传递给select函数,然后select函数会阻塞并等待任意一个文件描述符就绪。它适用于较小的并发连接数场景,因为它有文件描述符数量限制。
poll
poll是select的改进版本,解决了select的文件描述符数量限制问题。与select不同,poll使用一个pollfd结构数组来传递待监视的文件描述符,每个结构体中保存了要监视的文件描述符及其关注的事件。poll函数会阻塞并等待任意一个文件描述符就绪。
epoll
epoll是Linux特有的I/O多路复用机制。它通过epoll_create函数创建一个epoll实例,并使用epoll_ctl函数向其中添加或修改要监视的文件描述符和关注的事件。与select和poll不同,epoll将就绪的文件描述符放入一个就绪列表中,而不是阻塞等待,然后程序可以通过epoll_wait函数从就绪列表中获取就绪的文件描述符进行处理。
epoll相对于select和poll的优势在于:
- 没有文件描述符数量限制,支持大规模并发连接。
- 使用回调机制,只返回就绪的文件描述符,避免遍历整个文件描述符集合。
- 支持边缘触发模式,即只在状态发生变化时通知,提高效率。
总而言之,epoll、select和poll都是用于实现I/O多路复用的机制,选择使用哪种机制取决于具体的应用场景和操作系统支持。在Linux系统上,epoll通常是最佳选择,尤其对于大规模并发的网络应用。
45.Netty对比Java NIO做了什么优化?(必考)
Netty 是一个基于 Java NIO 的高性能网络应用框架,它在 Java NIO 的基础上做了一些优化和扩展,以提供更强大、更易用的网络编程能力。
下面是 Netty 相对于 Java NIO 做出的一些优化:
- 简化了编程模型:Netty 提供了更简单、更易用的编程模型,隐藏了 Java NIO 复杂的细节。它的事件驱动模型和回调机制使得编写高效、可扩展的网络应用更加简单。
- 提供了更高级的抽象:Netty 提供了一系列高级的抽象组件,如 Channel、ChannelHandler 和 ChannelPipeline,它们使得网络应用的开发更加模块化和可扩展。同时,Netty 还提供了丰富的编解码器,简化了网络数据的编解码过程。
- 内存管理优化:Netty 在内存管理上进行了优化,引入了零拷贝技术和内存池,减少了数据在内存之间的复制操作,提高了数据的传输效率。
- 提供了更强大的并发模型:Netty 支持多种并发模型,包括单线程模型、多线程模型和多线程池模型。它提供了基于事件驱动的方式进行网络处理,避免了传统的阻塞式 I/O 的性能瓶颈。
- 支持更多的协议和特性:Netty 提供了丰富的协议实现,包括 TCP、UDP、HTTP、WebSocket 等,同时还支持 SSL/TLS 加密和压缩、流量整形和拆包粘包处理等特性。
总体而言,Netty 在基于 Java NIO 的基础上进行了一系列的优化和扩展,使得开发者能够更轻松地构建高性能、可扩展的网络应用。它提供了简化的编程模型、高级抽象、内存管理优化、强大的并发模型和支持多种协议和特性,使得网络编程变得更加灵活、高效和可靠。
46.线程池关闭原理
线程池的关闭原理涉及到线程池的生命周期管理和任务处理的终止过程。下面是线程池关闭的一般原理:
- 停止接收新任务:首先,线程池需要停止接收新的任务提交。可以通过调用线程池的 shutdown() 方法来实现。此时,线程池将拒绝新的任务提交,并且不会再接受新的任务。
- 处理已提交的任务:一旦停止接收新的任务,线程池会继续处理已提交的任务。已经在等待队列中的任务将继续执行,正在执行的任务也会继续执行,直到所有的任务都执行完毕。
- 清空等待队列:在处理完已提交的任务后,线程池会尝试清空等待队列中的任务。可以通过调用线程池的 shutdownNow() 方法来实现。该方法将会尝试终止所有的任务,并返回等待队列中未执行的任务列表。
- 终止线程池:一旦等待队列中的任务被清空,线程池将会彻底终止。此时,所有的工作线程都将停止,并且线程池的状态将被标记为已终止。
需要注意的是,线程池的关闭并不会立即停止所有的任务。已经在执行的任务需要等待其执行完成,而等待队列中的任务可以选择是否继续等待执行或者被丢弃。同时,如果线程池中的任务存在依赖关系,需要注意任务之间的处理顺序,以免产生不可预期的结果。
在使用线程池时,建议在合适的时机进行关闭操作,以确保资源的正确释放和程序的正常终止。可以通过适当的方式监听线程池的关闭状态,以便在需要时进行后续的处理。
47.ThreadLocal原理和注意事项(必考)
ThreadLocal 是 Java 中一个用于线程级别数据存储的类,它提供了一种线程隔离的方式,让每个线程都可以独立地存储和访问自己的数据副本。下面是 ThreadLocal 的原理和一些注意事项:
原理
- 每个 ThreadLocal 对象都维护一个独立的数据副本。当调用 ThreadLocal 的 get() 方法时,它会先获取当前线程,然后使用当前线程作为 key 在内部的数据结构(ThreadLocalMap)中查找对应的数据副本。
- 如果当前线程不存在对应的数据副本,ThreadLocal 会使用初始值创建一个新的数据副本,并将其与当前线程关联。
- 当调用 ThreadLocal 的 set() 方法时,它会获取当前线程,并将传入的值关联到当前线程对应的数据副本上。
- 当线程结束或者调用 ThreadLocal 的 remove() 方法时,ThreadLocalMap 中与当前线程相关的数据副本会被清除,从而避免了内存泄漏。
注意事项
- 内存泄漏:由于 ThreadLocal 的特性,使用不当可能会导致内存泄漏。如果没有手动调用 remove() 方法,并且长时间保持对 ThreadLocal 对象的引用,那么数据副本可能无法被垃圾回收,从而导致内存泄漏。
- 共享变量问题:ThreadLocal 提供了线程隔离的数据副本,因此不适合用于在多个线程之间共享变量。如果多个线程需要共享某个变量,应该使用其他的线程同步机制,如锁或原子变量。
- 初始化值:ThreadLocal 在首次访问时会自动初始化一个值,可以通过重写 initialValue() 方法来指定初始值。如果未指定初始值,并且在访问前未调用 set() 方法,那么默认的初始值将为 null。
- 线程池使用时的注意:在线程池中使用 ThreadLocal 时需要格外小心,确保在任务执行完毕后手动调用 remove() 方法清理数据,以免数据混乱或内存泄漏的问题。
总而言之,ThreadLocal 提供了一种在每个线程中存储和访问数据的方式,但需要注意内存泄漏和线程之间共享变量的问题。合理使用 ThreadLocal 可以提高线程安全性和性能,但需要在适当的时机清理数据副本,以避免潜在的问题。
48.StringBuffer 和 StringBuilder 底层怎么实现的?
StringBuffer 和 StringBuilder 都是可变的字符串类,它们底层的实现方式略有不同:
StringBuffer
- StringBuffer 是线程安全的可变字符串类,它的底层实现使用字符数组(char[])来存储字符串内容。
- StringBuffer 内部维护了一个字符数组(value),以及表示字符串长度的变量(count)。
- 当进行字符串操作时,StringBuffer 会根据需要动态调整字符数组的大小,以容纳更多的字符。
- StringBuffer 的操作方法(如追加、插入、删除等)会对字符数组进行修改,并更新字符串的长度。
StringBuilder
- StringBuilder 也是可变字符串类,它与 StringBuffer 的区别在于线程安全性。
- StringBuilder 是非线程安全的,因此在多线程环境中使用时需要进行额外的同步措施。
- StringBuilder 的底层实现与 StringBuffer 类似,同样使用字符数组来存储字符串内容,并根据需要动态调整数组大小。
无论是 StringBuffer 还是 StringBuilder,它们的底层实现都是通过字符数组进行字符串的存储和操作。通过动态调整字符数组的大小,它们可以高效地进行字符串的修改和拼接操作。使用字符数组存储字符串内容的好处是可以避免频繁创建新的字符串对象,从而提高性能和内存利用率。
49.一个请求中,计算操作需要50ms,bd操作需要100ms,对于一台8核的机器来说,如果要求cpu利用率达到100%,如何设置线程数?
要使一台8核机器的CPU利用率达到100%,可以考虑以下几个因素来设置线程数:
- 考虑计算密集型任务:如果任务主要是计算密集型,即CPU密集型任务,可以将线程数设置为等于机器的核心数,即8个线程。这样可以充分利用机器的多核处理能力。
- 考虑阻塞操作:如果任务中存在阻塞操作,例如数据库查询、网络请求等,可以考虑使用线程池来管理线程,以便复用线程并提高任务的并发执行能力。在这种情况下,线程数可以设置大于机器的核心数,以便在某些线程阻塞时仍有其他线程可用于执行计算密集型任务。
- 考虑任务的特点和负载:根据任务的特点和负载情况,可以进行实验和性能测试,逐步增加线程数,并监测系统的性能指标,如响应时间、吞吐量和CPU利用率等。通过观察性能指标的变化,找到最佳的线程数配置。
- 考虑系统资源:除了CPU利用率外,还要考虑其他系统资源的使用情况,如内存、磁盘IO等。确保线程数的设置不会导致系统其他资源的过度使用或竞争,避免引起系统的性能下降。
需要注意的是,设置线程数时不一定需要将其设置得非常大。过多的线程数可能会导致线程切换开销增加,从而降低系统性能。根据任务的实际情况和系统的性能测试结果,进行合理的线程数设置,以达到最佳的性能和资源利用。
另外,还可以考虑使用异步编程模型、并发框架或分布式计算等技术来进一步提高系统的并发性和吞吐量,以满足高性能和高可扩展性的需求。
50.如果系统中不同的请求对应的cpu时间和io时间都不同,那怎么设置线程数量?
如果系统中不同请求对应的CPU时间和IO时间都不同,可以考虑以下方法来设置线程数量:
- 任务分类:首先,将不同类型的任务进行分类,根据任务的特点和执行时间进行划分。将CPU密集型任务和IO密集型任务进行区分。
- 分离处理:将CPU密集型任务和IO密集型任务分离处理。对于CPU密集型任务,可以根据机器的核心数来设置线程数量,以充分利用CPU的计算能力。对于IO密集型任务,可以采用异步编程模型,使用少量的线程来处理大量的IO操作。
- 线程池配置:对于IO密集型任务,可以使用线程池来管理线程。线程池可以根据实际情况进行配置,控制线程的数量和资源的利用。• 对于CPU密集型任务,可以设置线程池的核心线程数等于机器的核心数,以充分利用CPU的计算能力。• 对于IO密集型任务,可以根据系统的负载和IO操作的耗时情况来动态调整线程池的线程数量,以平衡CPU和IO的利用。
- 监控和调优:设置线程数量后,监控系统的性能指标,如响应时间、吞吐量、CPU利用率等。根据实际情况进行性能调优,逐步调整线程池的配置,以达到最佳的性能和资源利用。
需要注意的是,线程数量的设置是一个动态过程。根据系统的实际情况和负载情况进行监测和调整。不同类型的任务可能需要不同的线程池配置,因此,需要结合具体的业务场景和性能测试结果进行综合考虑。
51.Java实现多线程的方式有哪些?
Java实现多线程的方式有以下几种:
继承Thread类
创建一个继承自Thread类的子类,并重写其run()方法来定义线程的执行逻辑。然后通过创建子类的实例并调用start()方法启动线程。
class MyThread extends Thread {
public void run() {
// 线程执行的逻辑
}
}
// 创建并启动线程
MyThread thread = new MyThread();
thread.start();
实现Runnable接口
创建一个实现了Runnable接口的类,并实现其run()方法。然后通过创建该类的实例,并将其作为参数传递给Thread类的构造方法,最后调用start()方法启动线程。
class MyRunnable implements Runnable {
public void run() {
// 线程执行的逻辑
}
}
// 创建Runnable实例并作为参数传递给Thread构造方法
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
使用匿名内部类
可以使用匿名内部类的方式来实现线程。这种方式可以简化代码,尤其适用于简单的线程逻辑。
Thread thread = new Thread(new Runnable() {
public void run() {
// 线程执行的逻辑
}
});
thread.start();
使用线程池
Java提供了Executor框架,可以通过线程池来管理和调度线程的执行。通过创建线程池并提交任务,线程池会自动管理线程的生命周期和执行。
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.execute(new Runnable() {
public void run() {
// 线程执行的逻辑
}
});
executor.shutdown(); // 关闭线程池
这些是常见的Java实现多线程的方式,每种方式都有其适用的场景。选择合适的方式取决于具体的需求和设计。
52.Java处理多线程的方式有哪些?
Java处理多线程的方式有以下几种:
- 同步机制:使用关键字synchronized或使用Lock接口及其实现类(如ReentrantLock)进行线程同步。这可以确保多个线程之间的访问顺序和互斥访问共享资源,避免数据竞争和不一致的结果。
- 线程通信:使用等待/通知机制实现线程之间的协调和通信。可以使用wait()、notify()和notifyAll()方法在多个线程之间进行信号的发送和接收。
- 线程安全的容器:Java提供了一些线程安全的容器类,如Vector、ConcurrentHashMap、ConcurrentLinkedQueue等。这些容器类在多线程环境下提供了并发访问的安全性。
- 原子类:Java提供了一些原子类(Atomic classes),如AtomicInteger、AtomicLong、AtomicReference等。这些类提供了原子操作,可以保证操作的原子性,避免了线程间的竞争条件。
- 线程池:使用线程池管理和调度线程的执行。可以通过Executors类创建线程池,然后提交任务给线程池执行。线程池提供了线程的复用、线程数量的控制以及任务队列等功能。
- 并发工具类:Java提供了一些并发工具类,如CountDownLatch、CyclicBarrier、Semaphore等,用于多线程间的协作和控制。
- 并发框架:Java提供了并发框架,如Java 8引入的CompletableFuture和Stream API,可以简化多线程编程,实现异步和并行操作。
这些方式可以帮助处理多线程编程中的并发和同步问题,提高程序的性能和可靠性。具体使用哪种方式取决于具体的需求和场景。
53.Java指向的是引用还是地址?怎么理解
在Java中,变量存储的是引用而不是直接的内存地址。理解Java中变量指向的是引用而不是地址有以下几个关键点:
- 内存管理:Java的内存管理是由垃圾回收器负责的,它通过对引用的追踪和分析来确定对象的生命周期和回收时机。因此,Java中的变量存储的是对象的引用,而不是直接的内存地址。
- 对象生命周期:Java中的对象在堆内存中分配和销毁,而引用则用于访问这些对象。变量存储的引用指向对象所在的内存空间,可以通过引用来操作和访问对象的成员变量和方法。
- 引用的赋值和传递:在Java中,通过将引用赋值给变量或将引用传递给方法来操作对象。这意味着变量和方法参数存储的是引用,使得我们可以在不直接操作对象内存地址的情况下对对象进行操作。
- 引用的可变性:在Java中,引用是可变的,即可以通过改变引用的指向来指向不同的对象。这使得在程序执行过程中可以改变对象的访问方式,而不需要直接操作对象的内存地址。
因此,虽然在语义上可以说Java的变量指向对象的内存地址,但更准确地说,Java的变量存储的是引用,用于访问对象。这种引用的使用方式使得Java具有更高层次的内存管理和安全性,同时提供了更好的抽象和封装性。
54.ReentrantLock底层公平锁和非公平锁的原理
ReentrantLock是Java中的一个锁实现,它可以通过公平锁和非公平锁两种方式进行初始化。
- 底层公平锁(Fair Lock)会按照线程请求的顺序分配锁资源,即先到先得的原则。当一个线程请求锁时,如果锁当前是空闲状态,该线程将直接获得锁;如果锁已经被其他线程持有,该线程将进入等待队列,并在队列中等待轮到自己时才能获取锁。公平锁保证了锁的获取是按照线程请求的顺序进行的,避免了饥饿现象,但由于需要维护等待队列,可能会降低系统的整体性能。
- 底层非公平锁(Nonfair Lock)则允许新请求的线程在已持有锁的线程尚未释放锁时直接抢占锁资源。当一个线程请求锁时,如果锁当前是空闲状态,该线程将立即获得锁,不需要进入等待队列。如果锁已经被其他线程持有,当前线程将尝试插队,直接抢占锁资源。非公平锁通过允许抢占的方式提高了系统的吞吐量,但可能导致某些线程长期等待锁资源。
在底层实现上,ReentrantLock使用了AQS(AbstractQueuedSynchronizer)作为其同步器。AQS内部维护了一个等待队列,其中的节点表示等待线程,通过节点的状态来区分公平锁和非公平锁的行为。当一个线程获取锁失败时,它会被包装成一个节点并加入到等待队列中。
无论是公平锁还是非公平锁,ReentrantLock都通过AQS的机制实现了线程的等待和唤醒,以及对锁状态的控制和管理。这样可以确保在多线程环境下,每个线程按照特定的规则获取锁资源,实现线程安全和资源的合理利用。
55.Hashmap 线程不安全的原因
HashMap在多线程环境下是线程不安全的,主要有以下两个原因:
- 并发修改导致的数据结构破坏:当多个线程同时对HashMap进行插入、删除等操作时,可能会导致数据结构的破坏。例如,一个线程正在进行插入操作,而另一个线程正在进行删除操作,这可能会导致链表断裂、数据丢失等问题。
- 死锁和无限循环:HashMap的扩容操作(resize)可能触发死锁和无限循环。当多个线程同时触发HashMap的扩容操作时,它们可能会竞争对相同的桶进行操作,从而导致死锁或无限循环的情况发生。
为了在多线程环境下安全地使用HashMap,可以采取以下措施:
- 使用线程安全的Map实现:可以使用ConcurrentHashMap,它提供了线程安全的操作,并且具有较好的并发性能。
- 使用同步机制:可以使用显式的同步机制(如synchronized关键字或锁对象)来保护对HashMap的并发访问。通过在关键操作上加锁,可以确保同一时刻只有一个线程访问HashMap,从而避免线程安全问题。但这样做可能会降低并发性能。
需要注意的是,从Java 8开始,HashMap的实现已经做了一些改进,一部分线程安全问题得到了解决。在某些情况下,当多个线程对HashMap进行读操作时是安全的。然而,当涉及到写操作或同时进行读写操作时,仍然需要采取适当的线程安全措施。
56.Hash为啥要扩容
哈希表(Hash Table)在存储元素时使用哈希函数将元素的键映射到一个固定的数组位置上,这个数组被称为桶(bucket)。扩容是指在哈希表中的桶数量不足以容纳当前元素数量时,自动增加桶的数量。
哈希表扩容的主要目的是保持哈希表的负载因子(Load Factor)在一个合适的范围内。负载因子是指当前哈希表中存储元素的数量与桶的数量之比。
为什么需要控制负载因子呢?因为负载因子过高会导致哈希冲突的概率增加,即多个元素映射到同一个桶的可能性增大,进而降低哈希表的性能。通过扩容,可以增加桶的数量,从而降低负载因子,减少哈希冲突的发生,提高哈希表的效率和性能。
扩容的具体过程如下:
- 创建一个更大的桶数组,通常是原数组的两倍大小。
- 将原数组中的元素重新计算哈希值,并分配到新的桶中。
- 将元素存储到新的桶中。
- 最后,将新的桶数组替代原来的桶数组,完成扩容操作。
需要注意的是,哈希表的扩容是一项开销较大的操作,因为需要重新计算哈希值、重新分配桶,并且需要移动元素。为了减少频繁的扩容操作,通常在设计哈希表时会预估元素的数量,并根据预估值初始化合适大小的初始桶数组。此外,选择适当的负载因子阈值也是重要的,以平衡空间利用率和性能。
总结起来,哈希表扩容是为了保持合适的负载因子,减少哈希冲突,提高哈希表的性能和效率。
57.进程和线程的区别
进程和线程是操作系统中的两个重要概念,它们有以下区别:
- 定义:进程是操作系统分配资源的基本单位,是一个正在执行的程序的实例。线程是进程内的一个执行单元,是进程的实际运行单位。
- 资源占用:每个进程都有独立的内存空间、文件句柄、打开的文件等系统资源,进程之间的资源相互隔离。而线程是在进程内共享进程的资源,包括内存空间、文件句柄等。多个线程共享同一进程的资源,因此线程之间的通信和数据共享更为方便。
- 调度和切换:进程拥有自己的执行状态、程序计数器、栈等信息,需要操作系统进行进程切换和调度。而线程作为进程内的执行单元,由操作系统进行线程切换和调度。线程切换开销较小,因为线程共享相同的地址空间,切换时只需保存和恢复寄存器状态即可。
- 执行关系:一个程序至少包含一个进程,而进程可以包含多个线程。进程是多个线程的容器,线程是进程的实际执行单位。
- 并发性:多个进程之间可以并发执行,每个进程都有自己的一组线程。而线程是轻量级的执行单位,线程之间可以并发执行,一个进程的多个线程可以在不同的处理器上并行执行。
- 影响:进程的创建和销毁都需要较大的系统开销,包括分配内存空间、建立数据结构等。而线程的创建和销毁开销较小。
总结来说,进程和线程是操作系统中的两个基本概念,进程是资源分配的单位,而线程是执行的单位。进程之间相互独立,线程在同一进程内共享资源。线程切换开销小,可以实现更高效的并发执行。在设计和开发应用程序时,需要根据具体需求和系统架构选择适合的进程和线程模型。
58.并发和并行的区别
并发(Concurrency)和并行(Parallelism)是计算机领域中两个相关但不同的概念:
并发(Concurrency)
并发指的是在同一时间段内执行多个任务或处理多个事件。它强调多个任务之间的交替执行和共享资源的竞争。在并发情况下,多个任务通过快速的切换,使得它们似乎是同时执行的。并发可以提高系统的吞吐量和资源利用率,并改善响应时间。
并行(Parallelism)
并行指的是同时执行多个任务或处理多个事件。在并行情况下,多个任务真正地同时执行,每个任务占用不同的物理处理器核心或计算资源。并行利用了多核处理器或分布式系统的优势,通过同时处理多个任务来提高系统的处理能力和性能。
总结来说:
- 并发是在同一时间段内执行多个任务或处理多个事件,强调任务之间的交替执行和资源竞争。
- 并行是真正同时执行多个任务或处理多个事件,利用多核处理器或分布式系统的能力,提高处理能力和性能。
可以将并发视为一种逻辑上的概念,强调任务之间的关系和调度方式,而并行则是一种物理上的概念,强调任务的同时执行。
在实际应用中,通过并发和并行的技术可以提高系统的性能和响应能力。例如,通过多线程实现并发处理、利用多核处理器实现并行计算、使用分布式系统实现并行处理等。
59.进程调度的策略
进程调度是操作系统中负责决定哪些进程可以运行和使用CPU资源的过程。以下是一些常见的进程调度策略:
先来先服务(FCFS)
- 根据进程到达的先后顺序进行调度。
- 简单、公平,但可能导致长作业效应和低响应时间。
短作业优先(SJF)
- 优先调度估计运行时间最短的进程。
- 可以减少平均等待时间,但难以准确估计运行时间。
优先级调度
- 为每个进程分配一个优先级,并根据优先级进行调度。
- 可以根据进程的重要性和优先级分配CPU资源,但可能导致低优先级进程饥饿。
轮转调度(Round Robin)
- 按照时间片轮流分配CPU时间给每个进程,当时间片用完后,切换到下一个进程。
- 公平,保证每个进程都能获得CPU时间,但响应时间可能较长。
多级反馈队列调度
- 将进程按照优先级分组,每个组使用不同的调度算法,如轮转调度。
- 可以根据进程的行为和优先级进行灵活调度,平衡了公平性和响应时间。
最短剩余时间优先(SRTF)
- 在短作业优先的基础上,动态调整优先级和运行时间。
- 能够更准确地预测进程的运行时间,但可能导致频繁的上下文切换。
这些调度策略在实际应用中可以根据系统的需求和特点进行组合和优化。一些调度算法还会考虑优先级、响应时间、资源利用率、公平性和实时性等因素。
需要注意的是,调度策略的选择会对系统的性能和用户体验产生影响。不同的应用场景和需求可能需要选择不同的调度策略,以平衡不同的性能指标和优化系统的整体效能。
60.死锁发生的原因
死锁是指在并发系统中,两个或多个进程(或线程)因为争夺资源而被永久地阻塞,导致系统无法继续执行的状态。以下是导致死锁发生的常见原因:
- 互斥条件(Mutual Exclusion):某些资源一次只能被一个进程(或线程)使用,如果一个进程占用了资源,其他进程必须等待。
- 请求和保持条件(Hold and Wait):进程占有了至少一个资源,并且在等待其他进程的资源时保持对已占有资源的占用。
- 不可剥夺条件(No Preemption):资源只能由持有者显式地释放,其他进程无法抢占已被占用的资源。
- 循环等待条件(Circular Wait):多个进程形成一种循环等待资源的关系,每个进程都在等待下一个进程所占有的资源。
当这四个条件同时满足时,就可能发生死锁。当系统进入死锁状态后,没有外部干预,系统将无法恢复正常。
为了避免死锁的发生,可以采取以下策略:
- 破坏互斥条件:对于某些资源,允许多个进程共享或同时访问。
- 破坏请求和保持条件:进程请求资源时不保持已占有的资源,而是先释放已占有的资源再重新请求。
- 破坏不可剥夺条件:对于某些资源,允许系统剥夺已占有的资源,将其分配给其他进程。
- 破坏循环等待条件:通过定义资源的线性顺序,要求进程按顺序申请资源,避免形成循环等待的情况。
死锁是一种复杂的并发问题,需要细心的设计和合理的资源管理来避免。在实际开发中,可以使用死锁检测、死锁避免、死锁恢复等技术手段来处理死锁问题。
61.线程池核心数20,最大600,阳塞队列200,当gps200(注意是qps)的时候,请求(请求是调第三方,是一个长时间的任务)阻塞超时,请问怎么提高它的吞吐量(不能加机器)?
简单的一些思路如下:
- 调整线程池参数:根据当前的业务需求和系统资源情况,适当调整线程池的核心线程数、最大线程数和阻塞队列容量。根据给定的情况,可以尝试增加核心线程数、增加最大线程数、增加阻塞队列容量,或者选择不同的阻塞队列实现(如使用无界队列)。
- 使用合适的阻塞超时策略:当请求超时时,可以选择合适的阻塞超时策略,例如设置适当的超时时间,当超过一定时间仍未完成时,可以返回响应或进行其他处理,而不是一直阻塞等待。这样可以释放线程资源,提高线程的复用率。
- 异步化处理:考虑将第三方请求改为异步调用,即使用异步方式发送请求并获取结果。这样可以避免线程的阻塞等待,提高线程的并发处理能力。可以使用Java中的CompletableFuture、Future等异步编程机制来实现。
- 优化第三方调用:分析第三方请求的性能瓶颈,优化网络请求、IO操作、数据处理等方面的性能。可以考虑使用连接池、缓存机制、批量请求等技术手段来提高第三方调用的效率。
- 优化长时间任务:针对长时间的任务,可以考虑对任务进行拆分或分解,以减少单个任务的执行时间。例如,将长时间任务分成多个较小的子任务,并通过并行处理或异步调用来提高整体的吞吐量。
监控和调优:实时监控线程池的运行状态、请求处理时间和线程利用率等指标,根据监控数据进行调优和优化,找出性能瓶颈并针对性地进行改进。
需要注意的是,线程池的吞吐量受限于系统的资源和业务场景,无法无限提高。合理的调整和优化可以提高性能,但仍需综合考虑系统的资源消耗、稳定性和可用性等因素。
62.假设引用了一个第三方的jar 有个类和我自己写的代码类一样,那么在类加载机制过程中是如何处理的?
当引用了一个第三方的JAR文件,并且其中的类与你自己写的代码中的类同名时,Java类加载机制会按照特定的顺序来加载类。这个过程遵循类加载器的委托模型,主要分为三个步骤:
- 加载阶段: 首先由当前线程的类加载器(通常是系统类加载器)尝试加载类。它会查找类路径下的第一个匹配的类文件,如果找到了第三方JAR中的同名类,就会加载它,而不会再加载你自己写的同名类。
- **链接阶段: 在链接阶段,会对类进行验证、准备和解析。**在验证阶段,检查类的正确性;在准备阶段,为类的静态变量分配内存并初始化为默认值;在解析阶段,将符号引用解析为直接引用。这些步骤确保了类的合法性和正确性。
- 初始化阶段: 最后,在初始化阶段,执行类的静态初始化代码块和静态变量的赋值操作。此时,如果加载的是第三方JAR中的同名类,它会执行第三方JAR中的静态初始化,而不会执行你自己写的同名类的静态初始化。
通过这种委托模型,Java保证了类的加载、链接和初始化顺序的合理性。如果你希望优先加载自己写的同名类而不是第三方JAR中的同名类,可以考虑调整类加载器的加载路径或者使用不同的类加载器来加载不同的类。这样,你就可以控制类加载的顺序,确保正确的类被加载和执行。
63.基本类型和包装类区别
基本类型(Primitive Types)和包装类(Wrapper Classes)是Java中的两种数据类型,它们在某些方面有一些区别。
数据类型
- 基本类型:包括byte、short、int、long、float、double、char和boolean等8种基本数据类型。它们是直接存储数据值的,不具有方法和属性。
- 包装类:对应于每种基本类型,Java提供了相应的包装类,例如Byte、Short、Integer、Long、Float、Double、Character和Boolean等。包装类是引用类型,它们包装了对应基本类型的值,并提供了一些方法和属性来操作这些值。
对象和存储
- 基本类型:基本类型的变量直接存储在栈中,它们的值是直接存储的,没有指向其他对象的引用。
- 包装类:包装类是对象,存储在堆中,当创建包装类对象时,会在堆中分配内存,并将基本类型的值封装到包装类中。
自动装箱和拆箱
- 自动装箱:Java提供了自动装箱机制,即在需要包装类对象的地方,可以直接使用基本类型,系统会自动将其转换为对应的包装类对象。
- 拆箱:同样,当需要基本类型的值时,可以直接使用包装类对象,系统会自动将其转换为对应的基本类型值。
空值处理
- 基本类型:基本类型不能存储空值(null),因为它们不是对象,没有引用。
- 包装类:包装类可以存储空值(null),可以用于表示缺失数据或特殊情况。
性能
- 基本类型:由于基本类型直接存储值,因此处理速度更快,占用内存较少。
- 包装类:由于包装类是对象,需要额外的内存开销,并且在自动装箱和拆箱过程中会涉及到一些性能消耗。
在实际开发中,通常优先使用基本类型,只有在需要对象特性,例如泛型或集合中,才会使用对应的包装类。自动装箱和拆箱的特性使得基本类型和包装类之间的转换变得更加方便。
64.多线程对Long数据进行加和会存在什么问题?如何解决?
在多线程环境下对`Long`数据进行加和会存在并发安全性问题,主要涉及以下两个方面:
- 数据竞争:多个线程同时对同一个`Long`类型的数据进行加和操作时,可能会发生数据竞争,导致结果不正确。因为`Long`类型是64位的,而多线程的并发操作可能在同一时刻读取和写入数据,从而造成数据不一致性和错误的计算结果。
- 原子性问题:`Long`类型的加和操作不是原子性的,即不能在一个单独的操作中完成。它涉及读取当前值、加和操作、写入结果,这些操作之间可能被其他线程打断,从而导致不正确的计算结果。
解决这些问题的方法通常是使用原子操作或加锁来保证数据的正确性和一致性。Java提供了多种解决方案,其中一种常见的做法是使用`AtomicLong`类来进行原子操作:
import java.util.concurrent.atomic.AtomicLong;
public class AtomicLongExample {
private AtomicLong sum = new AtomicLong(0L);
public void addToSum(long value) {
sum.addAndGet(value);
}
public long getSum() {
return sum.get();
}
public static void main(String[] args) throws InterruptedException {
final AtomicLongExample example = new AtomicLongExample();
final int threadCount = 10;
final int iterations = 100000;
Runnable task = () -> {
for (int i = 0; i < iterations; i++) {
example.addToSum(1L);
}
};
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Final sum: " + example.getSum());
}
}
在上述示例中,使用`AtomicLong`来保证对`sum`的加和操作是原子的,从而避免了数据竞争和不正确的计算结果。
另外一种解决方案是使用锁(如`synchronized`关键字或`ReentrantLock`)来保护对`Long`数据的并发访问,确保每次只有一个线程能够对数据进行操作,从而保证数据的一致性和正确性。这样的做法虽然可以解决并发安全问题,但在高并发情况下可能会引起性能问题,因为锁会导致线程竞争和阻塞。因此,在选择解决方案时需要根据具体场景和需求来权衡利弊。
65.用java 代码实现一个死锁用例,说说怎么解决死锁问题?回到用例代码下,如何解决死锁问题呢?
死锁是一个并发编程中常见的问题,它发生在两个或更多线程互相持有对方所需要的资源而无法继续执行的情况。下面是用 Java 代码实现一个简单的死锁示例:
package org.zyf.javabasic.test.thread;
/**
* @program: zyfboot-javabasic
* @description: 死锁用例
* @author: zhangyanfeng
* @create: 2023-08-13 22:37
**/
public class DeadlockExample {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 1: Holding resource 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1: Waiting for resource 2...");
synchronized (resource2) {
System.out.println("Thread 1: Acquired resource 2!");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (resource2) {
System.out.println("Thread 2: Holding resource 2...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2: Waiting for resource 1...");
synchronized (resource1) {
System.out.println("Thread 2: Acquired resource 1!");
}
}
});
thread1.start();
thread2.start();
}
}
在这个示例中,两个线程(thread1 和 thread2)分别持有 resource1 和 resource2,并试图获取对方的资源。由于每个线程都在等待另一个线程释放资源,因此这段代码会导致死锁。
解决死锁问题需要采取一些常见的方法和策略,以确保线程在并发执行时不会发生死锁。以下是一些解决死锁问题的方法:
- 避免使用多个锁:尽量减少在代码中使用多个锁,这样可以减少死锁的可能性。如果有多个锁,确保线程按照相同的顺序获取锁,这样可以避免循环等待导致的死锁。
- 使用超时机制:在尝试获取锁时,设置一个超时时间,如果在超时时间内无法获取到锁,则放弃该操作,释放已经持有的锁,并进行回退操作,避免死锁发生。
- 使用Lock对象:Java提供了`java.util.concurrent.locks.Lock`接口,它比传统的synchronized块更加灵活,可以使用`tryLock()`方法尝试获取锁,并在获取失败时进行后续处理,从而避免死锁。
- 按顺序获取锁:在使用多个锁的情况下,确保线程按照固定的顺序获取锁,这样可以避免循环等待。
- 死锁检测:有些系统和工具可以进行死锁检测,监测程序运行时的锁和资源使用情况,如果发现潜在的死锁情况,可以采取相应的措施,例如中断某个线程,解除死锁。
- 避免长时间持有锁:在设计并发程序时,尽量避免长时间持有锁,尽快完成对资源的访问和操作,然后释放锁,从而减少死锁的可能性。
- 合理的资源分配策略:设计合理的资源分配策略,避免出现资源竞争的情况,从而减少死锁的发生。
请注意,死锁问题可能比较复杂,解决方法需要根据具体的代码和场景来确定。在设计并发程序时,要注意多线程之间的资源竞争和互斥关系,合理地选择锁和同步方式,并进行充分的测试和验证,以确保程序在运行时不会出现死锁问题。
在上面提供的死锁代码示例中,可以通过改变锁的获取顺序来解决死锁问题。确保线程在获取锁时按照相同的顺序来避免循环等待。具体来说,可以修改线程2的代码,将它的锁获取顺序与线程1相同,从而避免死锁。
下面是修改后的代码示例:
package org.zyf.javabasic.test.thread;
/**
* @program: zyfboot-javabasic
* @description: 死锁用例解决
* @author: zhangyanfeng
* @create: 2023-08-13 22:41
**/
public class DeadlockDealExample {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 1: Holding resource 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1: Waiting for resource 2...");
synchronized (resource2) {
System.out.println("Thread 1: Acquired resource 2!");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread 2: Holding resource 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2: Waiting for resource 2...");
synchronized (resource2) {
System.out.println("Thread 2: Acquired resource 2!");
}
}
});
thread1.start();
thread2.start();
}
}
通过将线程2的锁获取顺序调整为先获取resource1,再获取resource2,就能够避免死锁。当线程1持有resource1时,线程2无法获取resource1,从而避免了相互等待对方资源的情况,解决了死锁问题。
66.Netty 的线程机制是什么样的?
Netty是一个基于Java的异步事件驱动的网络应用框架,它的线程机制主要涉及两个方面:EventLoopGroup和EventLoop。
EventLoopGroup
- EventLoopGroup是一个线程池,它包含一组EventLoop。
- 它通常用于处理网络事件,如接受新连接和处理读写事件。
- 在应用程序启动时,可以创建一个或多个EventLoopGroup实例,其中一个用于处理连接的接受,另外一个用于处理连接的读写操作。
- EventLoopGroup通常有两种类型:单线程的和多线程的。
EventLoop
- EventLoop是Netty的核心组件,它代表了一个不断循环的处理任务。
- 每个EventLoop都与一个线程绑定,并负责处理任务队列中的事件。
- 它在事件循环中不断地从任务队列中获取任务并执行,直到任务队列为空或者遇到需要阻塞的任务。
- EventLoop使用非阻塞方式执行任务,避免了线程切换的开销,提高了性能和吞吐量。
Netty的线程模型采用了Reactor模式,其中EventLoop充当了事件处理器的角色,EventLoopGroup负责管理多个EventLoop,可以根据需要创建单线程或多线程的EventLoopGroup来适应不同的场景。这种设计使得Netty能够高效地处理并发连接和网络事件,提供了高性能的网络编程解决方案。
67.当前线程池是200,线程单次处理请求20ms,那么理论上单节点的qps 是多少呢?
理论 QPS(Queries Per Second,每秒查询次数)是衡量系统性能的一个重要指标,它表示在一秒钟内系统能够处理的查询或请求的数量。对于单节点系统,你可以使用以下公式来计算理论 QPS:
QPS = 1 / 平均请求响应时间
其中,平均请求响应时间是指系统从接收请求到完成响应的平均时间。这个时间通常以毫秒(ms)为单位。
注意:这个公式是一个理论上的近似值,实际的 QPS 可能会受到多种因素的影响,包括系统的硬件性能、软件优化、负载、并发性等等。
假设你有一个线程池,其中包含了 N 个线程,而平均每个请求的响应时间仍然是 T 毫秒。在这种情况下,你可以使用以下公式来计算理论 QPS:
QPS = N / T
这里,N 是线程池中的线程数量,T 是平均请求响应时间(以毫秒为单位)。
线程池能够提高并发处理能力,因此你可以同时处理更多的请求,这会影响到系统的理论 QPS。但仍然要注意,线程池的性能也会受到线程数量、线程调度、任务分配等因素的影响。因此,你在使用线程池的情况下,仍然需要进行性能测试和分析,以确定系统的实际性能情况。
根据提供的信息,当前线程池大小为 200,每个线程处理一个请求的时间为 20 毫秒。可以使用以下公式来计算理论上的单节点 QPS:
QPS = 线程池大小 / 单线程处理时间
将你提供的值代入公式:
QPS = 200 / (20 ms) = 200 / 0.02 s = 10000 QPS
理论上,单节点的 QPS 可以达到 10000。
然而,这个计算是基于理论情况下的近似值。在实际应用中,系统性能可能会受到多个因素的影响,包括线程调度、并发情况、硬件性能等。因此,在实际场景中,要进行性能测试和实际负载情况下的测试,以确定系统的实际性能和 QPS。
68.简单说下Lambda表达式,其解决了什么,相比java7的处理优化了什么?
Lambda 表达式是 Java 8 引入的一个重要特性,它提供了一种更简洁、更灵活的方式来编写匿名函数。Lambda 表达式的引入主要解决了以下两个问题,并在某些情况下优化了代码。
-
匿名内部类的冗余代码: 在 Java 7 及之前的版本中,要实现一个简单的功能,常常需要编写大量的匿名内部类。这些类会增加代码量并使代码显得冗余。Lambda 表达式通过简化匿名内部类的写法,让开发者能够更紧凑地表达逻辑,减少冗余代码。
-
代码可读性和可维护性: Lambda 表达式使代码更具可读性。通过将逻辑放在更接近使用它的地方,可以更清晰地传达代码的意图。这使得代码更易于理解和维护。
相比 Java 7 的方式,Lambda 表达式的引入在以下几个方面进行了优化:
-
简洁性: 使用 Lambda 表达式可以大大减少冗余的语法,让代码更加紧凑。特别是在处理集合、流式处理以及函数式编程方面,代码的可读性和简洁性得到了明显的提升。
-
迭代集合的优化: 在 Java 7 中,迭代集合需要通过 foreach 循环或迭代器来完成,而 Lambda 表达式和 Stream API 让集合的处理变得更加优雅,同时还能够自动利用多核处理器进行并行处理。
-
函数式编程: Lambda 表达式为 Java 引入了函数式编程的元素,使得在 Java 中更容易表达和使用函数式概念,如高阶函数、闭包等。
总之,Lambda 表达式的引入使 Java 编程更具现代化和函数式特性,使代码更具可读性、简洁性,同时提供了更好的性能优化和并行处理能力。这对于简化开发和编写高效代码都具有积极影响。
69.NIO(New I/O)用到的组件有哪些?
NIO(New I/O)是 Java 中的一组非阻塞 I/O 类库,引入了更为灵活和高效的 I/O 操作方式。在 NIO 中,有一些重要的组件和概念,以下是一些常见的 NIO 组件:
-
通道(Channels): 通道是连接到文件、套接字或其他可进行 I/O 操作的实体。它们类似于传统的流,但提供了更多的功能。通道可以用于读取、写入和操作数据。
-
缓冲区(Buffers): 缓冲区是一个内存区域,用于在通道和应用程序之间传输数据。NIO 缓冲区提供了不同类型的缓冲区(如 ByteBuffer、CharBuffer、IntBuffer 等),以适应不同类型的数据。
-
选择器(Selector): 选择器允许单个线程同时监视多个通道的 I/O 事件。使用选择器,可以实现非阻塞的多路复用 I/O 操作,以管理多个连接。
-
选择键(SelectionKey): 选择键是通道在选择器上注册的标记。它包含了通道的事件和状态信息,允许选择器跟踪通道的状态。
-
非阻塞 I/O: NIO 提供了非阻塞 I/O 操作,允许在数据没有准备好的情况下继续执行其他任务,而不是阻塞等待数据的到来。这在高并发环境中非常有用。
-
多路复用: NIO 的选择器允许一个线程同时处理多个通道的事件,从而实现多路复用。这在服务器端应用程序中非常有用,可以处理多个客户端连接。
-
通道间的数据传输: NIO 提供了直接通道间的数据传输方法,可以在通道之间高效地传输数据,避免了通过缓冲区中转的开销。
这些组件一起构成了 NIO 的核心架构,使得 Java 程序能够更高效地进行非阻塞 I/O 操作,适用于需要高并发处理的网络应用程序。
70.SpI和API区别是什么?SpI底层实现是什么?
API(Application Programming Interface)是一组定义了程序之间如何交互的规则和协议,提供了访问和使用某个软件组件、库或服务的接口。API 描述了如何调用和使用已经存在的功能。开发者可以通过调用 API 中的函数、方法等来使用底层的功能,而不需要关心具体的实现细节。
SPI(Service Provider Interface)则是一种设计模式,它用于在软件中提供可扩展的功能实现。SPI 允许开发者在不修改核心代码的情况下,通过插件或扩展点来增加或替换功能的实现。在 SPI 中,核心代码定义了一组接口或抽象类,而实际的实现则由不同的服务提供者来提供。这种设计方式使得系统的扩展性更好,可以更容易地添加新的功能实现。
API 和 SPI 的区别可以总结如下:
-
API(Application Programming Interface):
- 定义了如何与已经存在的功能或服务进行交互。
- 提供了使用已有功能的方法、函数、类等。
- 关注于如何正确地使用已有功能,而不关心实现细节。
- 使用 API 可以调用现有功能,但不可以随意添加新的实现。
-
SPI(Service Provider Interface):
- 是一种设计模式,用于实现插件化的扩展机制。
- 允许在不修改核心代码的情况下,通过插件添加或替换功能的实现。
- 定义了一组接口或抽象类,具体的实现由不同的服务提供者提供。
- 使用 SPI 可以动态地扩展和替换系统的功能实现。
在 Java 中,SPI 的底层实现通常是通过在 META-INF/services/
目录下创建配置文件,其中列出了实现了某个接口的类的全限定名。这些配置文件被加载器读取,以实现在运行时发现并加载不同的服务提供者。Java 标准库中的许多功能(如日志、数据库驱动、XML 解析器等)都使用了 SPI 设计模式来实现可扩展性。
今天只要你给我的文章点赞,我私藏的网安学习资料一样免费共享给你们,来看看有哪些东西。
网络安全学习资源分享:
最后给大家分享我自己学习的一份全套的网络安全学习资料,希望对想学习 网络安全的小伙伴们有帮助!
零基础入门
对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。
【点击领取】CSDN大礼包:《黑客&网络安全入门&进阶学习资源包》免费分享
1.学习路线图
攻击和防守要学的东西也不少,具体要学的东西我都写在了上面的路线图,如果你能学完它们,你去接私活完全没有问题。
2.视频教程
网上虽然也有很多的学习资源,但基本上都残缺不全的,这是我自己录的网安视频教程,上面路线图的每一个知识点,我都有配套的视频讲解。【点击领取视频教程】
技术文档也是我自己整理的,包括我参加大型网安行动、CTF和挖SRC漏洞的经验和技术要点,电子书也有200多本【点击领取技术文档】
(都打包成一块的了,不能一一展开,总共300多集)
3.技术文档和电子书
技术文档也是我自己整理的,包括我参加大型网安行动、CTF和挖SRC漏洞的经验和技术要点,电子书也有200多本【点击领取书籍】
4.工具包、面试题和源码
“工欲善其事必先利其器”我为大家总结出了最受欢迎的几十款款黑客工具。涉及范围主要集中在 信息收集、Android黑客工具、自动化工具、网络钓鱼等,感兴趣的同学不容错过。
最后就是我这几年整理的网安方面的面试题,如果你是要找网安方面的工作,它们绝对能帮你大忙。
这些题目都是大家在面试深信服、奇安信、腾讯或者其它大厂面试时经常遇到的,如果大家有好的题目或者好的见解欢迎分享。
参考解析:深信服官网、奇安信官网、Freebuf、csdn等
内容特点:条理清晰,含图像化表示更加易懂。
内容概要:包括 内网、操作系统、协议、渗透测试、安服、漏洞、注入、XSS、CSRF、SSRF、文件上传、文件下载、文件包含、XXE、逻辑漏洞、工具、SQLmap、NMAP、BP、MSF…
因篇幅有限,仅展示部分资料,需要点击下方链接即可前往获取
CSDN大礼包:《黑客&网络安全入门&进阶学习资源包》免费分享