大家好,又见面了,我是你们的朋友全栈君。
目录
1.HashMap和ConcurrentHashMap区别(必考)
4.HashMap1.7与HashMap1.8的区别,从数据结构上、Hash值的计算上、链表数据的插入方法、内部Entry类的实现上分析?
HashMap如果我想要让自己的Object作为K应该怎么办?
HashMap相关put操作,get操作等流程?(下图作为参考)
5.Hash1.7是基于数组和链表实现的,为什么不用双链表?HashMap1.8中引入红黑树的原因是?为什么要用红黑树而不是平衡二叉树?
6.HashMap、HashTable、ConcurrentHashMap的原理与区别?
7.volatile与synchronized的区别是什么?volatile作用(必考)
12.final、finnally、finalize的区别是什么?
线程池的关闭(shutdown或者shutdownNow方法)
17.核心线程池ThreadPoolExecutor的参数(必考)
18.ThreadPoolExecutor的工作流程(必考)
23.StringBuffer和StringBuilder的区别是什么?性能对比?如何鉴定线程安全?
String str=”hello world”和String str=new String(“hello world”)的区别?
24.Array和ArrayList有什么区别?使用时注意事项有哪些?
也可以自己手写一个:基于 HashMap 和 双向链表实现 LRU
27.ScheduledThreadPoolExecutor中的使用的是什么队列?内部如何实现任务排序的?
备注:针对基本问题做一些基本的总结,不是详细解答!
1.HashMap和ConcurrentHashMap区别(必考)
主要区别在多线程安全问题上:
- 多线程操作下HashMap无法保证数据同步,多线程修改HashMap并且有遍历的操作时,可能会产生ConcurrentModificationException异常。HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。
- 多线程操作下,在遍历ConcurrentHashMap时如果遍历过程中,该集合的结构发生变化,比如put,remove数据。这时不会抛出ConcurrentModificationException,能够正常遍历完成ConcurrentHashMap.
所以,对于其使用,有以下推介建议:
- 推荐的HashMap应用场景是单线程运行环境,并且不需要遍历操作的场景。这个推荐场景不是硬性条件。比如多线操作HashMap,我们通过加锁或者加入同步控制依然能正常应用HashMap,只是需要加上同步操作的代价。
- 多线程对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的环是如何产生的
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接口。
补充知识点:
HashMap如果我想要让自己的Object作为K应该怎么办?
- 重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞;
- 重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性;
HashMap相关put操作,get操作等流程?(下图作为参考)
HashSet和HashMap
- HashSet的value存的是一个static finial PRESENT = newObject()。
- 而HashSet的remove是使用HashMap实现,则是map.remove而map的移除会返回value,如果底层value都是存null,显然将无法分辨是否移除成功。
另外:Synchronized的实现原理
Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
5.Hash1.7是基于数组和链表实现的,为什么不用双链表?HashMap1.8中引入红黑树的原因是?为什么要用红黑树而不是平衡二叉树?
- 使用链表是为了解决哈希冲突,使用单链表就可以完成,使用双链表需要更大的存储空间。
- 为了提高HashMap的性能,在解决发生哈希碰撞后,链表过长导致索引效率慢的问题,同时红黑树解决快速增删改查特点。
- 红黑树的平衡度相比平衡二叉树要低,对于删除、插入数据之后重新构造树的开销要比平衡二叉树低,查询效率比普通二叉树高,所以选择性能相对折中的红黑树。
6.HashMap、HashTable、ConcurrentHashMap的原理与区别?
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
- 底层采用分段的数组+链表实现,线程安全
- 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
- Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
- 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
- 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容
7.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可能会造成线程阻塞。
8.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。所以说,在具体使用时要根据适当情况选择。
9.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调用底层指令实现。
两种方式:总线加锁或者缓存加锁保证原子性。
10.Java不可重入锁与可重入锁的区别如何理解?
可重入锁:
可重入锁就是一个类的A、B两个方法,A、B都有获得统一把锁,当A方法调用时,获得锁,在A方法的锁还没有被释放时,调用B方法时,B方法也获得该锁。
这种情景,可以是不同的线程分别调用这个两个方法。也可是同一个线程,A方法中调用B方法,这个线程调用A方法。
synchronized和java.util.concurrent.locks.ReentrantLock是可重入锁
不可重入锁:
不可重入锁就是一个类的A、B两个方法,A、B都有获得统一把锁,当A方法调用时,获得锁,在A方法的锁还没有被释放时,调用B方法时,B方法也获得不了该锁,必须等A方法释放掉这个锁。
补充问题:
AQS理论的数据结构是什么样的?
AQS内部有3个对象,一个是state(用于计数器,类似gc的回收计数器),一个是线程标记(当前线程是谁加锁的),一个是阻塞队列。
它内部实现主要是状态变量state和一个FIFO队列来完成,同步队列的头结点是当前获取到同步状态的结点,获取同步状态state失败的线程,会被构造成一个结点(或共享式或独占式)加入到同步队列尾部(采用自旋CAS来保证此操作的线程安全),随后线程会阻塞;释放时唤醒头结点的后继结点,使其加入对同步状态的争夺中。
AQS设计思想
- AQS使用一个int成员变量来表示同步状态
- 使用Node实现FIFO队列,可以用于构建锁或者其他同步装置
- AQS资源共享方式:独占Exclusive(排它锁模式)和共享Share(共享锁模式)
AQS它的所有子类中,要么实现并使用了它的独占功能的api,要么使用了共享锁的功能,而不会同时使用两套api,即便是最有名的子类ReentrantReadWriteLock也是通过两个内部类读锁和写锁分别实现了两套api来实现的。ReentrantLock,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。
state状态
state状态使用volatile int类型的变量,表示当前同步状态。state的访问方式有三种:
- getState()
- setState()
- compareAndSetState()
同步队列为什么称为FIFO呢?
因为只有前驱节点是head节点的节点才能被首先唤醒去进行同步状态的获取。当该节点获取到同步状态时,它会清除自己的值,将自己作为head节点,以便唤醒下一个节点。
11.多线程中sleep与wait的区别是什么?
因为从表象来看,好像sleep和wait都能使线程处于阻塞状态,但是却有着本质上的区别:
- sleep是线程中的方法,但是wait是Object中的方法。
- sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。
- sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。
- sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。
12.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之后的版本),这个方法已经逐渐被抛弃了。
13.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中读取的。
补充问题:
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到线程结束中间这段时间不会被回收的,就发生了我们认为的内存泄露。最要命的是线程对象不被回收的情况,这就发生了真正意义上的内存泄露。比如使用线程池的时候,线程结束是不会销毁的,会再次使用,就可能出现内存泄露。
14.为什么要使用线程池(必考)
Java的线程池是运用场景最多的并发框架,几乎所有需要异步或者并发执行任务的程序都可以使用线程池。
合理使用线程池能带来的好处:
- 降低资源消耗。 通过重复利用已经创建的线程降低线程创建的和销毁造成的消耗。例如,工作线程Woker会无线循环获取阻塞队列中的任务来执行。
- 提高响应速度。 当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,Java的线程池可以对线程资源进行统一分配、调优和监控。
补充问题:
线程池的线程数量怎么确定
- 一般来说,如果是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方法。
15.如何控制线程池线程的优先级
思路:
- 设定一个 orderNum,每个线程执行结束之后,更新 orderNum,指明下一个要执行的线程。并且唤醒所有的等待线程。
- 在每一个线程的开始,要 while 判断 orderNum 是否等于自己的要求值,不是,则 wait,是则执行本线程。
16.线程之间如何通信
线程间的四种通信方式
方式一:同步
这里讲的同步是指多个线程通过synchronized关键字这种方式来实现线程间的通信。这种方式,本质上就是“共享内存”式的通信。多个线程需要访问同一个共享变量,谁拿到了锁(获得了访问权限),谁就可以执行。
方式二:while轮询的方式
在这种方式下,线程A不断地改变条件,线程ThreadB不停地通过while语句检测这个条件(例如,list.size==5)是否成立 ,从而实现了线程间的通信。但是这种方式会浪费CPU资源。之所以说它浪费资源,是因为JVM调度器将CPU交给线程B执行时,它没做啥“有用”的工作,只是在不断地测试某个条件是否成立。
方式三:wait/notify机制
这里用到了Object类的 wait 和 notify 方法。
当条件未满足时(list.size !=5),线程A调用wait 放弃CPU,并进入阻塞状态。—不像while轮询那样占用CPU
当条件满足时,线程B调用 notify通知线程A,所谓通知线程A,就是唤醒线程A,并让它进入可运行状态。
这种方式的一个好处就是CPU的利用率提高了。
但是也有一些缺点:比如,线程B先执行,一下子添加了5个元素并调用了notify发送了通知,而此时线程A还执行;当线程A执行并调用wait时,那它永远就不可能被唤醒了。因为,线程B已经发了通知了,以后不再发通知了。这说明:通知过早,会打乱程序的执行逻辑。
方式四:管道通信
就是使用java.io.PipedInputStream 和 java.io.PipedOutputStream进行通信,更像消息传递机制,也就是说:通过管道,将一个线程中的消息发送给另一个。
17.核心线程池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: 构建线程的工厂类
补充问题:
常见线程池的创建参数是什么样的?
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
是不存储任务的,新的任务要么立即被已有线程执行,要么创建新的线程执行。
18.ThreadPoolExecutor的工作流程(必考)
基本背景思路:
一个新的任务到线程池时,线程池的处理流程如下:
- 线程池判断核心线程池里的线程是否都在执行任务。 如果不是,创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
- 线程池判断阻塞队列是否已满。 如果阻塞队列没有满,则将新提交的任务存储在阻塞队列中。如果阻塞队列已满,则进入下个流程。
- 线程池判断线程池里的线程是否都处于工作状态。 如果没有,则创建一个新的工作线程来执行任务。如果已满,则交给饱和策略来处理这个任务。
ThreadPoolExecutor类
具体的处理流程:
线程池的核心实现类是ThreadPoolExecutor类
,用来执行提交的任务。因此,任务提交到线程池时,具体的处理流程是由ThreadPoolExecutor类
的execute()方法去完成的。
- 如果当前运行的线程少于corePoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
- 如果当前运行的线程大于或等于corePoolSize,而且BlockingQueue未满,则将任务加入到BlockingQueue中。
- 如果BlockingQueue已满,而且当前运行的线程小于maximumPoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
- 如果当前运行的线程大于或等于maximumPoolSize,任务将被拒绝,并调用RejectExecutionHandler.rejectExecution()方法。即调用饱和策略对任务进行处理。
补充问题:
Java线程池的调优经验有哪些?(线程池的合理配置)
从以下几个角度分析任务的特性:
- 任务的性质:
CPU 密集型任务
、IO 密集型任务
和混合型任务
。 - 任务的优先级: 高、中、低。
- 任务的执行时间: 长、中、短。
- 任务的依赖性:
是否依赖其他系统资源
,如数据库连接
。
任务性质不同的任务可以用不同规模的线程池分开处理。 可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数。
- CPU 密集型任务配置尽可能小的线程,如配置 个线程的线程池。
- IO 密集型任务则由于线程并不是一直在执行任务,则配置尽可能多的线程,如 。
- 混合型任务,如果可以拆分,则将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务。只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率;如果这两个任务执行时间相差太大,则没必要进行分解。
优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理,它可以让优先级高的任务先得到执行。但是,如果一直有高优先级的任务加入到阻塞队列中,那么低优先级的任务可能永远不能执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,线程数应该设置得较大,这样才能更好的利用 CPU。
建议使用有界队列,有界队列能增加系统的稳定性和预警能力。可以根据需要设大一点,比如几千。使用无界队列,线程池的队列就会越来越大,有可能会撑满内存,导致整个系统不可用。
怎么对线程池进行有效监控?
以通过线程池提供的参数读线程池进行监控,有以下属性可以使用:
- taskCount:线程池需要执行的任务数量,包括已经执行完的、未执行的和正在执行的。
- completedTaskCount:线程池在运行过程中已完成的任务数量,completedTaskCount <= taskCount。
- largestPoolSize:线程池曾经创建过的最大线程数量,通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
- getPoolSize: 线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以线程池的线程数量只增不减。
- getActiveCount:获取活动的线程数。
通过继承线程池并重写线程池的 beforeExecute,afterExecute 和 terminated 方法,我们可以在任务执行前,执行后和线程池关闭前干一些事情。
19.Boolean占几个字节
未精确定义字节。
首先在Java中定义的八种基本数据类型中,除了其它七种类型都有明确的内存占用字节数外,就boolean类型没有给出具体的占用字节数,因为对虚拟机来说根本就不存在 boolean 这个类型,boolean类型在编译后会使用其他数据类型来表示。
boolean类型没有给出精确的定义,《Java虚拟机规范》给出了4个字节,和boolean数组1个字节的定义,具体还要看虚拟机实现是否按照规范来,所以1个字节、4个字节都是有可能的。这其实是运算效率和存储空间之间的博弈,两者都非常的重要。
20.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之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译器强制要求。
21.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方法:该方法唤醒在该对象上等待的所有线程。
22.Jdk1.8/Jdk1.7都分别新增了哪些特性?
jdk1.7新特性
泛型实例
的创建可以通过类型推断
来简化,可以去掉
后面new部分的泛型类型,只用<>
就可以了。并发工具增强
: fork-join框架最大的增强,充分利用多核特性
,将大问题分解
成各个子问题,由多个cpu可以同时 解决多个子问题
,最后合并结果
,继承RecursiveTask,实现compute方法,然后调用fork计算
,最后用join合并结果
。try-with-resources语句
是一种声明了一种或多种资源的try语句
。资源是指在程序用完了之后必须要关闭的对象。try-with-resources语句保证了每个声明了的资源在语句结束的时候都会被关闭
。任何实现了java.lang.AutoCloseable
接口的对象,和实现了java .io .Closeable
接口的对象,都可以当做资源使用
。Catch多个异常
:在Java 7中,catch代码块得到了升级,用以在单个catch块中处理多个异常
。如果你要捕获多个异常并且它们包含相似的代码,使用这一特性将会减少代码重复度。
jdk1.8新特性
- jdk1.8对hashMap等map集合的优化
- Lambda表达式
- 函数式接口
- 方法引用和构造器调用
- Stream API
- 并行流和串行流
- Optional容器:Java 8引入Optional类来
防止空指针异常
,Optional类最先是由Google的Guava项目引入的。Optional类实际上是个容器,它可以保存类型T的值
,或者保存null
。使用Optional类我们就不用显式进行空指针检查
了。 - 接口中的默认方法和静态方法
- 新时间日期API
- 定义可重复的注解:在
Java 5中使用注解有一个限制
,即相同的注解在同一位置只能声明一次
。Java 8引入重复注解
,这样相同的注解在同一地方也可以声明多次
。重复注解机制
本身需要用@Repeatable注解
。Java 8在编译器层做了优化,相同注解会以集合的方式保存
,因此底层的原理并没有变化。 - 扩展注解的支持:Java 8扩展了
注解的上下文
,几乎可以为任何东西
添加注解,包括局部变量
、泛型类
、父类与接口的实现
,连方法的异常
也能添加注解。 - jvm中的
方法区
变成了元数据区
(PermGen
变成了Metaspace
) 更好的类型推测机制
(不需要太多的强制类型转换了)编译器优化
:Java 8将方法的参数名加入了字节码中
,这样在运行时 通过反射 就能获取到参数名
,只需要在编译时使用-parameters参数。
23.StringBuffer和StringBuilder的区别是什么?性能对比?如何鉴定线程安全?
基本对比:
- String:String对象是不可变的。“对String对象的任何改变都不影响到原对象,相关的任何change操作都会生成新的对象”。
- StringBuilder:StringBuilder是可变的,它不是线程安全的。
- StringBuffer:StringBuffer也是可变的,它是线程安全的,所以它的开销比StringBuilder大
使用时的建议:
- 循环外字符串拼接可以直接使用String的+操作,没有必要通过StringBuilder进行append.
- 有循环体的话,好的做法是在循环外声明StringBuilder对象,在循环内进行手动append。不论循环多少层都只有一个StringBuilder对象。
- 当字符串相加操作较多的情况下,建议使用StringBuilder,如果采用了多线程,则使用StringBuffer。
如何鉴定线程安全:
查看源代码便一目了然,事实上,StringBuilder和StringBuffer类拥有的成员属性以及成员方法基本相同,区别是StringBuffer类的成员方法前面多了一个关键字:synchronized,不用多说,这个关键字是在多线程访问时起到安全保护作用的,也就是说StringBuffer是线程安全的。
补充问题:
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存在。
24.Array和ArrayList有什么区别?使用时注意事项有哪些?
- Array可以包含基本类型和对象类型,ArrayList只能包含对象类型。
- Array大小是固定的,ArrayList的大小是动态变化的。
- ArrayList提供了更多的方法和特性,比如:addAll(),removeAll(),iterator()等等。对于基本类型数据,集合使用自动装箱来减少编码工作量。但是,当处理固定大小的基本数据类型的时候,这种方式相对比较慢。
- ArrayList可以算是Array的加强版,(对array有所取舍的加强)。
如果想要保存一些在整个程序运行期间都会存在而且不变的数据,我们可以将它们放进一个全局数组里,但是如果我们单纯只是想要以数组的形式保存数据,而不对数据进行增加等操作,只是方便我们进行查找的话,那么,我们就选择ArrayList。
而且还有一个地方是必须知道的,就是如果我们需要对元素进行频繁的移动或删除,或者是处理的是超大量的数据,那么,使用ArrayList就真的不是一个好的选择,因为它的效率很低,使用数组进行这样的动作就很麻烦,那么,可以考虑选择LinkedList。
25.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
整体的设计思路是:可以使用 HashMap 存储 key,这样可以做到 save 和 get key的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。
LRU 存储是基于双向链表实现的,下面的图演示了它的原理。其中 h 代表双向链表的表头,t 代表尾部。首先预先设置 LRU 的容量,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
总结一下核心操作的步骤:
- save(key, value),首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。如果不存在,需要构造新的节点,并且尝试把节点塞到队头,如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。
- get(key),通过 HashMap 找到 LRU 链表节点,把节点插入到队头,返回缓存的值。
定义基本结构:
class DLinkedNode {
String key;
int value;
DLinkedNode pre;
DLinkedNode post;
}
具体手写代码如下:
public class LRUCache {
private Hashtable<Integer, DLinkedNode> cache = new Hashtable<Integer, DLinkedNode>();
private int count;
private int capacity;
private DLinkedNode head, tail;
public LRUCache(int capacity) {
this.count = 0;
this.capacity = capacity;
head = new DLinkedNode();
head.pre = null;
tail = new DLinkedNode();
tail.post = null;
head.post = tail;
tail.pre = head;
}
public int get(String key) {
DLinkedNode node = cache.get(key);
if(node == null){
/**should raise exception here.*/
return -1;
}
// move the accessed node to the head;
this.moveToHead(node);
return node.value;
}
public void set(String key, int value) {
DLinkedNode node = cache.get(key);
if(node == null){
DLinkedNode newNode = new DLinkedNode();
newNode.key = key;
newNode.value = value;
this.cache.put(key, newNode);
this.addNode(newNode);
++count;
if(count > capacity){
// pop the tail
DLinkedNode tail = this.popTail();
this.cache.remove(tail.key);
--count;
}
}else{
// update the value.
node.value = value;
this.moveToHead(node);
}
}
/**
* Always add the new node right after head;
*/
private void addNode(DLinkedNode node){
node.pre = head;
node.post = head.post;
head.post.pre = node;
head.post = node;
}
/**
* Remove an existing node from the linked list.
*/
private void removeNode(DLinkedNode node){
DLinkedNode pre = node.pre;
DLinkedNode post = node.post;
pre.post = post;
post.pre = pre;
}
/**
* Move certain node in between to the head.
*/
private void moveToHead(DLinkedNode node){
this.removeNode(node);
this.addNode(node);
}
// pop the current tail.
private DLinkedNode popTail(){
DLinkedNode res = tail.pre;
this.removeNode(res);
return res;
}
}
其他相关内容补充:
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高。
26.CAS?CAS 有什么缺陷,如何解决?
CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。如 Intel 处理器,比较并交换通过指令的 cmpxchg 系列实现。
CAS操作ABA问题:
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。
27.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作为任务队列。
- 获取任务的方式不同。
- 执行周期任务后,增加了额外的处理。
参考书籍、文献和资料
1.https://yuanrengu.com/2020/ba184259.html 对hashmap总结的还是比较全面的
2.https://blog.csdn.net/sinbadfreedom/article/details/80375048
3.https://blog.csdn.net/weixin_40096176/article/details/80350891
4.https://blog.csdn.net/u013597226/article/details/96910719
5.https://blog.csdn.net/lovezhaohaimig/article/details/86595113
6.https://www.cnblogs.com/heyonggang/p/9112731.html
7.https://www.cnblogs.com/wangtianze/p/6690665.html?utm_source=itdadao&utm_medium=referral
8.https://blog.csdn.net/lov2_y1y1/article/details/87919005
9.https://github.com/yuanguangxin/LeetCode/blob/master/Rocket.md
10.https://baijiahao.baidu.com/s?id=1637483028541742298&wfr=spider&for=pc
11.https://www.php.cn/faq/416709.html
12.https://blog.csdn.net/m0_37602175/article/details/80271647
13.https://blog.csdn.net/csdnlijingran/article/details/88855000
14.https://blog.csdn.net/huideveloper/article/details/80632111
15.https://blog.csdn.net/suchahaerkang/article/details/80456085
16.https://www.jb51.net/article/163879.htm
17.https://blog.csdn.net/qq_39199837/article/details/100309472
18.https://baijiahao.baidu.com/s?id=1647423693517849309&wfr=spider&for=pc
19.https://www.sohu.com/a/320856927_505800
20.https://blog.csdn.net/u014454538/article/details/96910729
21.https://blog.csdn.net/weixin_43610698/article/details/90812353
22.https://www.cnblogs.com/williamjie/p/11164283.html
23.https://blog.csdn.net/elricboa/article/details/78847305
24.https://zhuanlan.zhihu.com/p/91949778
25.https://blog.csdn.net/hopeztm/article/details/79547052
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/125654.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...