Java集合
Java集合类的继承结构和各自的适用情况
Collection
— List
— ArrayList:动态数组
— LinkedList:底层是双向链表,应用于Queue接口可以用于实现队列,应用于Deque接口可以用于实现栈
— Vector:线程安全的动态数组,但由于性能较低已被废弃(其下有Stack)
— Set
— HashSet:哈希集合,底层数据结构是哈希表
— LinkedHashSet:底层数据结构是哈希表+链表,可以保证先进先出
— TreeSet:底层数据结构是红黑树
— Queue
— PriorityQueue:优先队列
— Deque:Double Ended Queue,双端队列,可以在队列两端进行插入、删除操作,因此既可以作为队列,也可以作为栈
— ArrayDeque
— LinkedList
Map
— HashMap(哈希表)
List适用于需要顺序存储的情况
Set适用于需要存储无顺序、不重复的情况
Queue适用于队列存储
Map适用于存储键值对
List
1. ArrayList底层实现
ArrayList底层是Object数组。
ArrayList实现了以下接口:
List:是一个顺序列表
RandomAccess:支持快速随机访问
Cloneable:支持深拷贝、浅拷贝
Serializable:支持序列化
观察ArrayList源码,默认长度是10,默认一开始是空数组,当插入第一个元素时,数组长度变为默认的10。当继续插入元素时,如果数组中元素达到长度极限,调用grow方法扩容数组,如果没有达到极限,直接插入。
观察ArrayList的构造方法,有三种。一种是什么都不传入,默认是空数组。第二种是传入容量,则创建对应大小的数组。第三种是传入Collection接口下的集合,将其他集合类型转为ArrayList。
2. ArrayList的扩容原理
当调用add方法添加元素时,首先调用ensureCapacityInternal方法,判断当前元素数量和当前数组长度的关系。如果数组够用,继续插入元素。如果数组不够用,调用grow方法进行数组扩容。
grow方法:
使用位运算将数组容量扩充为原来的1.5倍,检查是否满足需求,如果满足,将新容量作为数组容量,如果不满足,将最小需求作为数组容量。调用Arrays.copyOf方法创建一个拥有新容量的数组,并把原数组复制过去。
3. ArrayList与Vector的区别
ArrayList是线程不安全的,Vector内部调用了synchronized关键字,是线程安全的
4. ArrayList插入、删除元素的时间复杂度是多少
头部插入元素:所有元素右移,时间复杂度O(n)
尾部插入元素:如果不需要扩容,时间复杂度O(1);如果需要扩容,需要复制元素到新数组,时间复杂度O(n)
某一位置插入元素:右边所有元素右移,时间复杂度O(n)
头部删除元素:所有元素左移,时间复杂度O(n)
尾部删除元素:O(1)
某一位置删除元素:右边所有元素右移,时间复杂度O(n)
5. ArrayList是否实现了RandomAccess接口
RandomAccess是一个标记接口,表示该类支持快速随机访问(也就是通过索引在O(1)时间内快速访问某元素)。
ArrayList可以通过索引访问,是支持RandomAccess接口的。
6. LinkedList的底层原理是什么
LinkedList的底层原理是双向链表,内部的链表节点中存储:本节点值data、前一个节点指针prev、后一个节点指针next。
LinkedList实现的接口:
List:支持顺序存储
Deque:支持双端队列,由此可以作为栈或队列的底层数组结构
Cloneable:支持拷贝
Serializable:支持序列化
7. LinkedList插入、删除元素的时间复杂度
头部插入/删除:O(1)
尾部插入/删除:O(1)
指定位置插入、删除:需要遍历到指定位置,O(n)
8. LinkedList如何作为队列和栈
作为队列:
Queue<Integer> queue = new LinkedList();
queue.offer(1); // 添加元素
queue.poll(); // 弹出队首元素
queue.peek(); // 获取队首元素但不弹出
作为栈:
Deque<Integer> stack = new LinkedList();
stack.push(1); // 入栈
stack.pop(); // 出栈
stack.peek(); // 获取栈顶元素但不弹出
另外,在回溯问题中,会使用LinkedList存储path,在添加入path时使用path.add(),在遍历完当前path后使用removeLast()方法撤销。最后,在将path添加到List<> ans中时,将其转为ArrayList,ans.add(new ArrayList(path));
9. CopyOnWriteArrayList保证线程安全的原理
ArrayList是线程不安全的,Vector是线程安全的,但Vector实现线程安全的方式是读写都加synchronized,性能较低,目前已被废弃。
现在实现线程安全的动态数组的是CopyOnWriteArrayList。
CopyOnWriteArrayList保证线程安全的原理是读写锁,即读不加锁,写加锁,这样保证了读操作的性能。
在此基础上,使用写时复制(CopyOnWrite, COW)策略,进一步保证了写操作也不会影响读操作。具体来说,当需要进行写操作时,不会直接修改原数组,而是创建一份副本,在副本数组上进行修改,修改完后再将数组复制回去。这样保证了写操作不会影响读操作。
但这样做也有一些缺点:
1)写操作时间、空间开销,每次写操作都要复制一份数组,改后再复制回去
2)可能导致数据一致性问题
Set
1. HashSet底层原理
HashSet底层是用HashMap实现的,具体来说,HashSet的元素实际上存储在HashMap的key中,而value存储一个固定的Object对象。
HashSet的特点是无序性和不可重复性。
无序性指的是:HashSet不按元素插入顺序进行存储,而是根据其哈希值进行存储。
不可重复性是指:HashSet内部不允许出现重复元素,因此经常被用来去重。
2. HashSet插入、删除元素的时间复杂度
HashSet插入、删除元素的时间复杂度都是O(1)。
3. HashSet、LinkedHashSet、TreeSet的适用场景分别是什么
HashSet适用于无序、无重复元素场景。
LinkedHashSet适用于需要先进先出的场景。
TreeSet适用于需要自定义排序的场景。
Queue
1. Queue和Deque的区别
Queue是单端队列,只能队尾插入元素,队首删除元素。
Deque是双端队列,两端都能插入、删除。
2. ArrayQueue和LinkedList区别
ArrayQueue和LinkedList都实现了Deque接口,都可以实现双端队列的功能。
但二者的底层数据结构不一样。ArrayQueue是基于动态数组和双指针实现的,LinkedList是基于双向链表实现的。
3. PriorityQueue底层数据结构是什么,插入删除的时间复杂度是多少
底层数据结构是二叉堆,即父节点的值一定小于或等于子节点的值。PriorityQueue poll出的堆顶元素一定是最小的。
插入、删除元素的时间复杂度是O(logn)
遍历priorityqueue需要先将其放入ArrayList中,然后使用for-each遍历:
List<Integer> pq_list = new ArrayList(pq);
for(Integer i : pq_list) {System.out.println(i);
}
自定义排序:
PriorityQueue<Integer> pq = new PriorityQueue<Integer>(new Comparable<Integer>(){@Overridepublic int compare(Integer i1, Integer i2) {return i2 - i1; // 大顶堆}
});
4. ArrayBlockingQueue是什么
ArrayBlockingQueue是阻塞队列,主要用于生产者和消费者之间的通信。
即生产者线程向其中添加元素,消费者线程从其中提取元素。
可以分别实现阻塞式和非阻塞式的put和take。
阻塞式:put和take,当队列满时,阻塞put操作,直至队列有空余。当队列空时,阻塞take操作,直至队列有元素。
非阻塞式:offer和poll,当队列满时,不阻塞,而是offer方法直接返回false,添加失败;当队列空时,不阻塞,而是poll直接返回null,获取到的是空。
使用BlockingQueue实现生产者—消费者模式:
class Producer extends Thread {private BlockingQueue<String> bq;public Producer(BlockingQueue<String> bq) {this.bq = bq;}@Overridepublic void run() {for (int i = 1; i <= 10; i++) {String new_product = "Product " + i;try {bq.put(new_product);System.out.println("Produced: " + new_product);} catch (InterruptedException e) {e.printStackTrace();}}}
}class Consumer extends Thread {private BlockingQueue<String> bq;public Consumer(BlockingQueue<String> bq) {this.bq = bq;}@Overridepublic void run() {for (int i = 1; i <= 10; i++) {try {String product = bq.take();System.out.println("Consumed: " + product);} catch (InterruptedException e) {e.printStackTrace();}}}
}public class Main {public static void main(String[] args) {BlockingQueue<String> bq = new ArrayBlockingQueue(10);Producer producer = new Producer(bq);Consumer consumer = new Consumer(bq);producer.start();consumer.start();}
}
5. DelayQueue是什么
DelayQueue是延迟队列,可以用于实现延时任务,例如“订单下单15分钟未支付自动取消”功能。
DelayQueue中的元素必须实现Delayed接口,并重写getDelay()方法,用于计算是否到期。DelayQueue底层是用PriorityQueue实现的,默认按照到期时间升序排列,当getDealy()方法返回值小于0时,移除队列。
Map
1. HashMap底层原理
HashMap底层原理是哈希表+链表,即元素首先根据key的hashcode计算哈希值,存储在对应哈希表中。如果出现哈希碰撞,使用拉链法解决。当链表长度大于8时,会将链表转为红黑树,以提高搜索效率。(当整个哈希表长度不超过64时,不会转为红黑树,而是进行数组扩容)
HashMap添加、删除元素的时间复杂度:
如果没有出现哈希冲突,是O(1)。
如果出现了哈希冲突,且哈希冲突用链表解决,是O(n)
如果出现了哈希冲突,且哈希冲突用红黑树解决,是O(logn)
2. HashMap数组扩容的原理
HashMap默认长度是16,默认负载因子是0.75,也就是说,75%的数组有数据,25%的数据无数据,这是为了尽量减少哈希碰撞。当数组元素数量>数组容量*负载因子时,会对hashmap的数组进行扩容。
数组扩容的原理:
默认将数组扩容为原来的2倍。扩容方式是:根据新的长度重新计算所有元素的哈希值,将原来的元素复制到新的哈希位置。
3. HashMap数组长度为什么是2的幂次方/为什么扩容是乘以2
1)可以用位运算进行取余操作来获得哈希位置,效率更高
2)可以保证哈希值分布比较均匀
3)扩容时只需检查哈希值高位来判断是否变化位置
4. HashMap插入新元素的流程
首先,判断哈希数组是否为空,如果为空,先扩容到默认值16.
其次,计算哈希值。
如果对应哈希位置没有元素,直接插入在这一位置。
如果对应哈希位置有元素,判断这一元素key与要插入的key是否相同,如果相同直接修改对应value。
如果不相同,判断这一位置上的元素的红黑树节点还是链表节点。
如果是红黑树节点,调用putTreeVal放入树中。
如果是链表节点,遍历到链表,如果有相同的key,直接修改对应value,如果没有,遍历到链表末尾后在末尾插入新元素。在链表末尾插入元素后,判断是否需求将链表转为红黑树。
插入完成后,根据数组长度和负载因子判断是否需要扩容。
5. JDK1.7以前的HashMap在多线程环境下为什么会导致死循环
JDK1.7以前的Hashmap链表,插入新元素时采用的是头插法,而多线程环境下,多个线程同时操作链表,可能导致头节点指向错误的位置,从而导致形成环形链表。
为了解决这个问题,JDK1.8以后链表插入新元素改为了尾插法。
但多线程环境下还是建议使用ConcurrentHashMap。因此即使没有死循环的问题,多线程环境下也可能导致数据覆盖、数据丢失等情况。
6. 如何遍历hashmap
三种方法:1)使用keySet;2)使用valueSet;3)使用entrySet;
HashMap<Integer, Integer> map = new HashMap();
for(Integer key : map.keySet()){}
for(Integer value: map.valueSet()){}
for(Map.Entry<Integer, Integer> entry : map.entrySet()){}
7. ConcurrentHashMap是怎么保证线程安全的
JDK1.8以前,ConcurrentHashMap是通过分段加锁保证线程安全的。
而JDK1.8以后,是使用CAS+synchronized关键字实现的,只锁定当前数组元素或链表和红黑树的头节点,锁粒度更细,只要不在同一个节点,就不影响其他线程,效率更高。
当插入新元素时,先根据key计算出哈希值,如果当前位置没有元素,使用CAS尝试插入元素,如果失败就不断重复尝试自旋插入,如果成功,直接break。
如果当前位置有元素,获取synchronized锁,锁定当前链表/红黑树插入元素。
8. LinkedHashMap的底层原理
LinkedHashMap底层是使用HashMap+双向链表实现的,在hashmap哈希存储节点时,还维护了一个双向链表,来存储元素的插入顺序。
当使用迭代器遍历LinkedHashMap时,按照插入顺序遍历。
9. LinkedHashMap如何按照访问顺序存储
当希望按照访问顺序输出时,只需将LinkedHashMap的accessOrder设置为true,这样,当使用get方法访问元素时,会将其移动到链表末尾。当按照链表遍历时,即可得到访问顺序输出。
10. LinkedHashMap如何实现LRU缓存
LinkedHashMap可以用于实现LRU缓存。
1)继承LinkedHashMap
2)构造方法:调用super构造方法并设置accessOrder为true,当访问某元素时,将其移到链表最末尾。存储容量。
3)重写LinkedHashMap的removeEldestEntry方法,这个方法返回一个布尔值,告知LinkedHashMap是否需要移除链表head元素,在这个方法中,判断链表长度是否超过容量。
Java并发
线程和进程
1. 什么是进程,什么是线程,Java中是如何规定的
进程是内存分配的基本单位,线程是CPU调度的基本单位。线程是轻量级的进程,执行开销小,但因为共享部分资源,不利于资源的管理和保护。
Java中多个线程共享进程的堆和方法区,但每个线程有独立的程序计数器、虚拟机栈、本地方法栈。
Java中的线程是内核级线程,一个Java线程对应一个内核线程,可以利用多核CPU。
程序计数器为什么是私有的?
程序计数器用于指示线程执行的位置,保证线程切换时能够恢复到正确的位置,因此必须是线程私有的。
虚拟机栈用于存储线程执行中的局部变量、操作数栈、常量池引用等信息,每个线程都不一样,所以是线程私有的。
本地方法栈与虚拟机栈类似,但虚拟机栈存储的是Java方法执行信息,本地方法栈存储的是Native方法执行信息。
2. 线程的生命周期
初始态(NEW):线程刚被创建,还没有调用start方法执行
运行态(RUNNABLE):线程调用start方法执行
阻塞态(BLOCKED):线程被锁阻塞
等待态(Waiting):线程需要等待其他线程通知或中断,调用wait方法可以进入该状态
超时等待态(TIME_WAITING):同样等待其他线程动作,但超时后自动返回。使用sleep(long millis)或wait(long millis)可以进入该状态
终止态(TERMINATED):线程运行完毕
3. Java中如何创建线程
有两种方法:
1)继承Thread类
public class MyThread extends Thread {@Overridepublic void run() {// }
}MyThread myThread = new MyThread();
myThread.start();
2)实现Runnable接口
public class MyRunnable implements Runnable {@Overridepublic void run() {//}
}
Thread myThread = new Thread(new MyRunnable());
myThread.start();
4. 什么是线程的上下文,什么时候会发生上下文切换
线程的上下文就是线程执行所需的程序计数器、虚拟机栈等信息。
线程上下文切换就是指当切换线程时,先保存当前线程的上下文信息,然后将下一个线程的上下文信息加载到CPU中。
线程切换的几种情况:
1)调用sleep()、wait()方法时主动让出CPU
2)时间片用完
3)发生阻塞,例如IO阻塞
4)被终止或线程结束运行
5. Thread的sleep方法和Object类的wait方法有什么异同
相同:二者都会暂停线程的执行,让出CPU
不同:sleep方法不会释放锁,wait方法会释放锁。wait后线程不会主动苏醒,必须有其他线程notify;sleep到时间后线程会自动苏醒。
6. 可以直接调用Thread类的run方法来启动线程吗
不能。直接调用Thread类的run方法只是将这个方法当作main线程中的一个普通方法去调用,不会启动新线程。而调用Thread类的start方法时,会启动一个新线程,执行相应准备工作,然后调用run方法。
7. 什么是并发,什么是并行
并发:多个线程在同一时间段内同时执行
并行:多个线程在同一时刻同时执行
8. 什么是同步,什么是异步
同步:发出一个调用后必须等待调用返回才能继续执行
异步:发出一个调用后不等待返回,直接向下执行
9. 为什么要使用多线程
1)从计算机底层角度来说,对于单核CPU,在执行耗费时间的IO操作时可以切换到其他线程,提高CPU利用率;对于多核CPU,可以有效利用多核CPU的能力。
2)从互联网发展趋势来说,多线程并发编程是提高实现系统高并发的基础。
10. Java使用的系统线程调度方式是什么
操作系统调度线程有两种方式:抢占式和协同式。
抢占式:操作系统决定何时切换线程,会造成上下文切换的开销,但公平性较好,不容易出现线程阻塞;
协同式:线程自己决定何时切换并通知系统,上下文切换开销较小,但公平性较差。
Java使用的是抢占式,JVM本身不负责线程调度,而是交给操作系统。
11. 单核CPU能运行多线程吗,一定能够提高效率吗
单核CPU可以运行多线程,操作系统通过时间片轮转的方式将CPU时间分配给不同线程。
对于IO密集型任务,可以提高效率,因为在执行IO任务时可以将CPU给其他线程使用;
对于CPU密集型任务,不能提高效率,因为大量线程都需要使用CPU,进行线程切换反而会造成上下文切换的开销。
12.什么是死锁,死锁形成的四个条件,怎样检测、预防、避免死锁
死锁是指线程循环等待其他线程持有的锁,导致多个线程被同时阻塞。
死锁形成的四个条件:
1)互斥条件:一个锁不能被多个线程占有
2)请求和保持条件:线程已占用一些资源,且同时请求另一些资源,而且等待资源时不释放已有资源
3)不剥夺条件:线程占有的资源未使用完之前不会被其他线程强行剥夺,只能自己使用完毕后释放
4)循环等待条件:多个线程构成循环等待链
检测死锁:
可以用jmap、jstack等查看堆栈内存的分配情况;或使用Visual VM、JConsole等工具,连接对应程序后排查死锁。
预防死锁:
- 破坏互斥条件:一般不太可行,因为很多资源本身就是天然互斥的。
- 破坏请求与保持条件:可以要求进程一次性请求所有需要的资源,而不是逐步请求。
- 破坏不可剥夺条件:当一个进程请求新的资源得不到满足时,释放已占有的资源。
- 破坏循环等待条件:可以对资源进行编号,规定进程只能按照编号递增的顺序请求资源。
避免死锁:
银行家算法:通过提前判断资源分配的安全性,来决定是否为进程分配资源。
JMM
1. JMM是什么
JMM是Java内存模型,它是Java程序与JVM实际内存区域之间的一种抽象的模型,并不是真实存在的。
JMM将JVM内存区域分为主内存和本地内存。主内存是所有线程共有的,所有线程创建的对象实例都存储在主内存中。本地内存是线程私有的,本地内存中存储的是主内存中共享变量的副本。
2. happens-before原则和指令重排序
happens-before原则按照程序顺序规则、解锁规则、volatile变量规则、传递规则、线程启动规则等梳理指令的顺序。
指令重排序时根据梳理出的happens-before规则来进行。
a happens-before b的含义是,a指令的结果对b是可见的
3. 并发编程的三个特性
原子性:一个原子操作要么不执行,如果执行就要执行完
可见性:线程对共享变量的修改对所有线程都是可见的。Java中用volatile关键字保证变量的可见性,底层原理是强制变量是主存中分配
有序性:JVM编译器进行指令重排序时,会保证按照happens-before原则进行,这是单线程环境下是正确的,但多线程环境下不一定保证正确
Java并发常用关键字和类
1. volatile关键字有什么作用
volatile关键字有两个作用:
1)保证变量的可见性,指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取;
2)禁止指令重排序,如果将一个变量声明为volatile,在对这个变量进行读写时,会插入特定的内存屏障的方式来禁止指令重排序。
volatile只能保证变量的可见性,不能保证对变量操作的原子性。
2. 双重校验锁实现单例模式
public class Singleton {private volatile static Singleton singleInstance;private Singleton() {}public static Singleton getSingleInstance() {if (singleInstance == null) {synchronized(Singleton.class) {if (singleInstance == null) {singleInstance = new Singleton();}}}return singleInstance;}
}
第一次校验:在同步代码块之外校验单例是否为空,如果不为空直接返回,避免进入同步代码块;
第二次校验:在进入同步代码块之后,再次校验单例是否为空,避免多个线程同时通过第一次校验进行同步代码块,导致创建多个实例。
另外,使用volatile关键字的作用:1)保证单例变量的修改对所有线程可见,避免出现一个线程初始化变量后,其他线程检测到的仍然是null的情况。2)禁止指令重排序,避免出现已分配内存还没初始化的时候,由于指令重排序,直接返回了没有初始化的内存地址的情况。
3. 乐观锁和悲观锁是什么,各有什么优势和缺点,适合什么场景
乐观锁是指总是假设最好的情况,认为共享资源每次被访问都不会出现问题,因此不加锁,只是在提交修改时验证资源是否被其他线程修改了,验证方法:CAS算法/版本号机制。
悲观锁,总是假设最坏的情况,认为共享资源每次被访问都会出现问题,因此每次操作都加锁。
乐观锁可以避免锁竞争造成线程阻塞,也不会有死锁问题。但当写占比非常多的情况下,冲突频繁发生,会不断尝试自旋重试,因此会导致频繁失败重试影响性能。因此乐观锁更适用于读操作较多,写操作较少的场景。
悲观锁可以避免频繁失败重试影响性能,但无法避免锁的开销。因此悲观锁更适用于写操作较多,读操作较少的场景。
4.乐观锁的实现方式:CAS算法
CAS算法:Compare and Swap,当变量当前值等于期望值时,认为没有线程修改过变量,可以更新。如果不相等,自旋重复尝试。
Java中CAS算法是用Unsafe类实现的,提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong等方法,这些方法都是native方法,说明是用本地代码实现的,通常是C或C++
CAS算法可能的问题:
1)ABA问题
即变量先是A,被其他线程改成了B,又被另一个线程改回了A。此时与期望值仍然相同,CAS算法误以为没有被其他线程修改过。
2)循环开销时间大
由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是自旋锁机制,这会导致循环开销。
3)只能保证一个共享变量的原子操作
CAS操作仅对单个共享变量有效。
解决ABA问题的方法:
版本号机制:在变量前加版本号或时间戳,先检测变量值是否等于预期值,再检查变量版本号是否等于预期版本号。版本号一般是在前面加一个version字段,每次修改version++。
4. synchronized关键字是什么,有什么作用,如何使用,底层原理是什么
synchronized关键字用于保证资源访问同步性,它可以保证任何时刻只有一个线程进入代码块。
synchronized关键字有三种使用方法:
- 修饰实例方法:获取对象锁
- 修饰静态方法:获取类锁
- 修饰代码块:取决于括号里的参数,如果是类就是类锁,如果是某个object对象或this,就是对象锁
synchronized关键字的底层原理:
synchronized修饰代码块时,本质是使用monitorenter指令和monitorexit指令,其中monitorenter指向代码块起始位置,monitorexit指向代码块结束位置。
synchronized修饰方法时,没有使用monitorenter和monitorexit指令,而是给方法添加ACC_SYNCHRONIZED标识,表示该方法是一个同步方法。
但无论是使用monitorenter/monitorexit还是使用ACC_SYNCHRONIZED标识,本质都是获取JVM的监视器锁Monitor。
5. synchronized锁升级
每个对象头中会有有个Mark Word,用于记录哈希码、锁状态、GC分代年龄等信息。
对象的锁状态有四种:
1)无锁状态。
2)偏向锁:当只有一个线程访问同步代码块时,会使用偏向锁;
3)轻量级锁:当多个线程竞争锁时,JVM 会将偏向锁升级为轻量级锁。轻量级锁通过 CAS 操作尝试获取锁,避免线程阻塞。
4)重量级锁:当自旋等待超过一定次数后,JVM 会将锁升级为重量级锁。重量级锁会导致线程阻塞,进入操作系统内核态,性能开销较大。
synchronized修饰的代码块会使锁随线程数增多不断升级,锁只能升级不能降级。
synchronized和volatile的关系
synchronized和volatile是互补的,经常互相配合使用。
synchronized用于修饰代码块,保证多线程访问资源的同步性。volatile用于修饰变量,保证变量的可见性。
volatile只能保证可见性和有序性,不能保证原子性。synchronized三者都能保证。
volatile比synchronized更轻量级。
6. ReentrantLock是什么
ReentrantLock是可重入锁,即如果一个线程已经占有这个锁,当它再次请求这个锁时可以请求到。
实际上synchronized锁也是可重入的,但ReetrantLock比synchronized功能更完善。
ReentrantLock有公平锁和非公平锁两种实现,默认使用非公平锁。
ReentrantLock底层是用AQS实现的。
7. synchronized与ReentrantLock的异同
相同:都是可重入锁
不同:
1)ReentrantLock可以实现公平锁也可以实现非公平锁,synchronized只能实现非公平锁;
2)synchronized基于JVM实现,ReentrantLock基于JDK实现
3)ReentrantLock还提供了等待中断、超时、条件等高级功能。
7. 公平锁、非公平锁;可中断锁、不可中断锁;共享锁、独占锁
公平锁:按照申请顺序分配锁,保证了时间上的绝对顺序,但性能较差;
非公平锁:不按申请顺序,而是按照一定的优先级分配锁。
可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。ReentrantLock就属于是可中断锁。
不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized就属于是不可中断锁。
共享锁:一把锁可以被多个线程同时获得,比如读锁是共享锁
独占锁:一把锁只能被一个线程获得,比如写锁是独占锁
8. 读写锁
读写锁既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
一般锁的互斥条件:读读互斥、读写互斥、写写互斥
读写锁的互斥原理:读读不互斥、读写互斥、写写互斥
Java中实现的读写锁:ReentrantReadWriteLock、StampedLock。其中ReentrantReadWriteLock是可重入的读写锁,StampedLock是不可重入的读写锁。
读写锁适用于读多写少的场景。
AQS
AQS全称是AbstractQueueSynchronizer,即抽象队列同步器。
顾名思义,AQS是一个用于构建锁和同步器的抽象类,主要提供了可重入锁、信号量、倒计时器等一些通用框架。
AQS的底层原理
AQS底层是基于CLH锁队列的变体实现的。
CLH锁是一种基于CAS算法的优化。在CAS算法中,获取不到锁时会自旋重复获取。这种自旋方式可能导致某个线程长时间获取不到锁,造成饥饿。
为了解决这个问题,CLH引入了一个队列来组织并发竞争的线程。每个竞争的线程按顺序加入到队列中排队,保证公平性。
当使用CAS方法获取锁失败时,先短暂自旋尝试获取,如果仍然失败,阻塞线程加入队列等待被唤醒,防止重复自旋占用CPU。
AQS中CLH锁队列
队列中由各个节点构成,每个节点都有自己的状态。
例如三个线程获取锁时,T1先获取到,初始化等待队列,其中有一个头节点,状态为0;当T2请求锁时,锁已被占用,将T2加入等待队列中,放在头节点的next位置,此时头结点的状态改为SIGNAL(-1),表示当前节点退出时需唤醒后继节点;同样T3申请锁时加入等待队列,放在T2的后面,T2状态改为SIGNAL(-1);当T1释放锁时,唤醒后继节点T2,T2获取锁,成为新的head。
Semaphore
用于访问数量有限的资源,控制访问资源的线程数量。
使用场景:数据库连接池、线程池等数量有限的资源
CountDownLatch
CountDownLatch的作用是让count个线程阻塞,直到所有线程执行完毕。
使用场景:确保在某些任务完成后再执行后续操作,例如多个线程完成数据加载后再进行数据处理。
CyclicBarrier
CyclicBarrier的作用是让count个线程阻塞在屏障处,直到所有线程到达屏障。
使用场景:多线程并行计算时,在某个点合并结果再继续计算。
Atomic原子类
Atomic原子类是指具有原子操作特性的类,使用原子类的变量在操作时要么完整执行,要么不执行。
Atomic实现原理是CAS算法。
Atomic原子类有哪些?
1)基本类型原子类:AtomicInteger、AtomicLong、AtomicBoolean
2)数组类型原子类:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
3)引用类型原子类:AtomicReference、**
AtomicStampedReference**、
AtomicMarkableReference
4)对象属性修改原子类:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater原子更新引用类型中某个字段
Atomic的应用场景:
并发计数
ThreadLocal
对于ThreadLocal创建的变量,每个线程都有一个本地副本。线程内通过threadLocal.get()获取,通过threadLocal().set()设置其值。
ThreadLocal应用场景
数据库连接管理:每个线程可以有一个独立的数据库连接,避免了多个线程共享数据库连接的问题。
ThreadLocal底层原理
每个线程内部都有一个ThreadLocalMap,当用ThreadLocal创建变量时,ThreadLocal为key,对应的变量Object类为value,存储在map中。
ThreadLocal什么情况下会导致内存泄漏?
正常使用继承Thread类来创建线程是不会导致内存泄漏的。因为线程的ThreadLocalMap只被当前线程引用,当前线程结束任务退出以后,这种引用消失,线程对应的ThreadLocalMap就会被GC回收。
但实际项目开发中多使用线程池来实现多线程,这种情况下使用ThreadLocal就可能导致内存泄漏。因为线程池中的线程不会退出,是循环使用的,所以对应的ThreadLocalMap的引用不会消失,ThreadLocalMap不会被GC回收。但是ThreadLocalMap内部存储的是Entry数组,其中有多个key-value对,其中key是ThreadLocal对象,是弱引用的,value是对应的Object对象,是强引用的。当当前线程执行完毕,投入下一次使用时,弱引用的key会被GC回收,但value是强引用的,不会被回收,这就导致value对应的key为null,长期无法被回收就会导致内存泄漏。
如何避免内存泄漏?
每次使用完ThreadLocal对象后,调用remove方法将其移除ThreadLocalMap