文章目录
集合
集合体系
Collection
List: 可以重复,有序的
ArrayList:
底层数组实现,查询快,(数组中间)增删慢, 浪费空间
第一次添加时,初始化一个默认容量为10
创建时,通过构造方法指定初始容量
扩容: 添加满了之后,会发生扩容 grow() 扩容为原来的1.5倍
如何保证ArrayList线程安全
- synchronizedList 底层相当于把集合的set add remove加上synchronize锁
- 使用 CopyOnWriteArrayList其线程安全是通过加可重入锁ReentrantLock来保证的(适合读多写少)
- 自定义
LinkedList:
底层是双向链表, 查询慢,增删快 实现队列,栈
什么时候用ArrayList,LinkedList 查找,删除
Vector: 底层是数组 ,线程安全的.若未指定扩容值,扩容2倍
集合遍历
-
for 支持增删(注意索引变化) 增强for(只能删除一个,报错 使用break)
-
迭代器(里面有一个计数器)
iterator();从前向后
listIterator(arrayList.size()); 从指定的位置开始遍历
从后向前listIterator.hasPrevious() previous() -
stream 流式
Set 不能重复
Set和Map的关系:
二者都不保存重复的元素,存储一组唯一的对象
HashSet 底层封装了HashMap, TreeSet底层封装了TreeMap
为什么重写了equals()方法还需要重写hashCode()方法?
public boolean equals(Object obj) {
return (this == obj);
}
equals()方法比较基本数据类型比较的是值,如果是对象的话,会判断对象属性是否相同,hashcode判断地址是否相同,判断两个对象相等必须满足 地址+属性
如果只重写equals()可能会出现两个没有关系的对象equals相同,但hashcode不相同的情况.因为调用的是object的hashcode,默认的hashcode方法根据对象的内存地址计算得来,所以两个对象不相同,hashcode值也不相等
但是根据hashcode规则,两个对象相等那么hashcode值也一定相等,有矛盾.
哈希冲突的几种解决方案,各个优缺点?
-
开放定址法
这种方法也称再散列法,其基本思想是:当关键字 key 的哈希地址 p=H(key)出现冲突时,以 p 为基础,产生另一个哈希地址 p1,如果 p1 仍然冲突,再以 p 为基础,产生另一个哈希地址 p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。
-
再哈希法(再散列法)
这种方法是同时构造多个不同的哈希函数:
Hi=RH1(key) i=1,2,…,k
当哈希地址Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。 -
链地址法(拉链法)
这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
HashMap 解决 hash 冲突就采用的这种方式!
HashMap结构
哈希表+链表+红黑树
使用红黑树是为了提升查找数据的速度,插入数据会进行左旋右旋保持平衡
创HashMap是默认哈希表容量是16 也可以指定长度,允许键值为空
添加时首先是通过k的哈希值,再通过哈希函数计算位置,
位置上如果没有元素添加在链表的头结点,如果有插入到链表的下一个节点.
链表的长度>=8,转为红黑树
JDK1.8 前HashMap采用头插法,效率高于尾插发,不需要遍历一遍进行数据插入
JDK1.8后采用尾插法,是为了判断链表的查毒是否大于8
解决哈希冲突采用:链表发 开放定址法 再哈希法 建立公共溢出区
扩展 负载因子 默认为0.75 (为什么)
1 效率低
0.5 浪费空间
扩容为原来的2倍(为什么)
效率高
减少哈希重冲突
HashMap闭环
原因是线程一被挂起时是仍然保留原链表的当前元素e和下一个元素next,而线程二扩容完成以后由于采用头插法已经将当前元素e和下一个元素next位置进行互换,这样导致线程一在扩容过程中会将当前元素e进行两次处理。(第一次是线程被唤起时处理,第二次是采用线程二互换的关系重新定位到e),结果导致出现链表闭环。
ConcurrentHashMap1.8
Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。
ConcurrentHashMap 内部通过加锁(自旋锁 + CAS + synchronized + 分段锁)来保证线程安全。
通过自旋和CAS操作完成
初始化 Segment 流程:
- 检查 Segment 是否为null.
- 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。
- 再次检查计算得到的指定位置的 Segment 是否为null.
- 使用创建的 HashEntry 数组初始化这个 Segment.
- 自旋判断计算得到的指定位置的 Segment 是否为null,使用 CAS 在这个位置赋值为 Segment.
put
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。
get
- 根据 hash 值计算位置。
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
- 如果是链表,遍历查找之。
扩容rehash
ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
描述一下ConcurrentHashMap中的hash寻址算法
- 首先是通过 Node 节点的 Key 获取到它的 HashCode 值,再将 HashCode 值通过
spread(int h)
方法进行绕道运算,进而得到最终的 Hash 值。 - 获取到最终的 hash 值后,再通过寻址公式:
index = (tab.length -1) & hash
获得桶位下标。
HashTable结构
数组+链表 键值不能为空 线程安全 使用synchronized锁住
默认11 扩容*2+1
采用奇数导致hash冲突少
TreeSet TreeMap 底层是红黑树结构 根据内容的自然顺序排序
泛型
为了参数化类型,或者说可以将类型当作参数传递给一个类或者是方法。
- 它提供了一种扩展能力。它更符合面向抽象开发的软件编程宗旨。
- 当具体的类型确定后,泛型又提供了一种类型检测的机制,只有相匹配的数据才能正常的赋值,否则编译器就不通过。
- 泛型提高了程序代码的可读性,不必要等到运行的时候才去强制转换,在定义或者实例化阶段,因为 Cache这个类型显化的效果,程序员能够一目了然猜测出代码要操作的数据类型。
通配符有 3 种形式。
<?>
被称作无限定的通配符。<? extends T>
被称作有上限的通配符。<? super T>
被称作有下限的通配符。
类型擦除
泛型信息只存在于代码编译阶段,在进入 JVM 之前,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除
List<String> l1 = new ArrayList<String>();
List<Integer> l2 = new ArrayList<Integer>();
System.out.println(l1.getClass() == l2.getClass());//true
类型擦除带来的局限性
抹掉很多继承相关的特性,这是它带来的局限性。
IO 延伸到操作系统
常见的IO模型
- 按照流的流向分,可以分为输入流和输出流;
- 按照操作单元划分,可以划分为字节流和字符流;
- 按照流的角色划分为节点流和处理流。
Java IO 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系, Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
- InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
- OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
既然有了字节流,为什么还要有字符流?
问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
回答:字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。
BIO 同步阻塞
应用发起read调用后,会一直阻塞,直到内核把数据拷贝到用户空间
适合客户端数量不多的情况
NIO 同步非阻塞
IO多路复用模型中,线程首先发起select调用,询问内核数据是否准备就绪,等内核数据准备好后,用户线程再次发起read调用。(read调用从内核空间到用户空间还是阻塞的)
select 调用 :内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
对于高负载,高并发的网络应用
客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。
应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的
I/O多路复用
线程首先发起select调用,询问内核数据是否准备就绪,好了以后,用户线程发起read调用,read调用的过程(数据从内核-》用户空间),还是阻塞的
通过减少无效的系统调用,减少对cpu资源的消耗
AIO 异步IO
基于事件和回调机制实现的,在应用操作之后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作
流的类型
字节流
输入流,输出流
InputStream OutputStream
字符流
输入流,输出流
节点流
FIleInputStream FileOutputStream 直接来操作文件 read() read(byte[] b)
处理流
BufferedInputStream 缓存功能 提示效率
对象序列化,反序列化
ObjectInputStream ObjectOutputStream 反序列化 一种创建对象方式 深克隆
线程 延伸到操作系统,调度算法
线程,进程名词解释,关系
进程: 程序执行的一次过程,系统运行程序的基本单位
线程: 线程是进程更小的执行单位
多个线程共享进程的堆和方法区,每个线程有自己的程序计数器,本地方法栈,虚拟机栈
如何创建线程
- 继承Thread类,重写run方法,创建实例,执行start方法
- 实现Runable接口,实现run方法
- 实现callable接口,实现call方法,结合FutureTask类包装Callable对象,实现多线程
- 通过线程池创建线程,实现runnable接口,重写run方法,创建线程池,调用执行方法并传入对象,高性能,复用线程
线程的 run() 和 start() 有什么区别?
run():普通的方法调用函数,在主线程执行,不会新建一个新线程
通过start()启动一个线程进入就绪状态,等待cpu,然后线程调用run方法执行线程任务
Runnable和Callable的区别
都需要调用start方法启动线程
实现Runnable接口,重写run方法,不能抛出异常
实现callable接口,重写call方法,允许抛出异常,支持线程返回执行结果
Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程直到获取‘将来’结果;当不调用此方法时,主线程不会阻塞!
线程间的同步的方式
- 互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。
- 信号量(Semphares) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量
- 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操
线程间通信方式
-
共享内存,基于 volatile关键字来实现线程间相互通信是使用共享内存的思想,大致意思就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
-
消息传递,Object类提供了线程间通信的方法:wait()、notify()、notifyaAl(),它们是多线程通信的基础,而这种实现方式的思想自然是线程间通信。
-
使用JUC工具类 CountDownLatch,基于AQS框架,相当于也是维护了一个线程间共享变量state
线程的状态
-
新建
-
就绪: cpu调度进入运行状态
-
运行
-
阻塞: wait() sleep() join() i/o请求
-
死亡: 线程执行完毕或者异常
sleep: Thread的方法, 不会释放锁,抱着锁睡觉
yield: Thread的方法,暂停当前线程,执行其他,交出cpu使用权,不会释放锁
join: Thread方法,在主线程运行,让主线程休眠,不会释放锁,让调用join方法的线程先执行,在执行其他线程
wait: 释放锁
并发:并行
并发一个cpu,交替执行多个任务
并行: 多个cpu,同时执行多个任务
多线程优缺点
一个进程内允许多个线程执行
优点: 提高cpu的利用率,榨取cpu
缺点:
安全性,以及切换时候的开销大
死锁问题
可见性,原子性,有序性
java内存模型:
规范jvm和计算机内存的协同工作
缓解 cpu/io/内存之间的速度差异
可见性问题
如何访问共享变量
线程安全问题:并发编程
可见性
一个线程对共享资源进行操作后,另一个线程可以立即看到
产生原因
为了均衡内存和cpu的速度差异,在cpu加了缓存,为了提高效率,写缓冲区合并,在进行多次写操作时,缓存数据不会及时刷新到主内存中
处理
volatile,保证可见性进而有序性
当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存
写操作会导致其他线程中的缓存无效
具体原理: MESI缓存一致性协议
MESI协议保证了每个缓存中使用的共享变量的副本是一致的
核心的思想:多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。
监听和通知又基于总线嗅探机制来完成
原子性
一个或多个操作在cpu执行时无法被中断
产生原因
多线程在多cpu运行,线程切换导致
解决
- synchronized锁
- 使用juc类下的Atomic原子类,它是使用CAS和volatile实现原子操作,类中的value有volatile修饰
有序性
程序按照代码的先后顺序执行
产生原因
编译器为了优化性能,会改变程序中的语句的先后顺序
解决
volatile关键字
volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的
死锁
死锁根本原因:
是多个线程涉及到多个锁,这些锁存在着交叉,所以可能会导致了一个锁依赖的闭环;
一个线程T1持有锁L1,并且申请获得锁L2;而另一个线程T2持有锁L2,并且申请获得锁L1,因为默认的锁申请操作都是阻塞的,所以线程T1和T2永远被阻塞了。导致了死锁。
java 死锁产生的四个必要条件:
1、互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用(这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问))
2、不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。(占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。)
3、请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。(一次性申请所有的资源。)
4、循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
既然我们知道了产生死锁可能性的原因,那么就可以在编码时进行规避。
如何避免
1、避免嵌套锁
2、只锁需要的部分
3、避免无限期等待
银行家算法
当一个进程申请使用资源的时候,银行家算法通过先 试探 分配给该进程资源,然后通过安全性算法判断分配后的系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待。
守护线程
setDaemon() 等待所有线程执行完毕才会销毁
jvm里的GC 就是守护线程
线程间通信
线程与线程之间不是相互独立的个体,它们彼此之间需要相互通信和协作,最典型的例子就是生产者-消费者问题.
线程间的通信方式: wait/notify机制
生产者-消费者问题
/*柜台中 存放共享数据*/
public class Counter {
int num = 0;
/*生产商品*/
public synchronized void add() {
if (num == 0) {
num++;
System.out.println("生产一个");
this.notify();//唤醒消费者线程 this表示同一个柜台
} else {
try {
this.wait();//生产者等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/*消费商品*/
public synchronized void sub() {
if (num == 1) {
num--;
System.out.println("消费一个");
this.notify();//唤醒生产者线程 this表示同一个柜台
} else {
try {
this.wait();//消费者等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
/*消费者线程 */
public class Customer extends Thread {
Counter c;
public Customer(Counter c) {
this.c = c;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
c.sub();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Productor extends Thread {
Counter c;
public Productor(Counter c) {
this.c = c;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
c.add();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Test {
public static void main(String[] args) {
Counter c = new Counter();//创建柜台对象,是生产或者和消费者
Productor p = new Productor(c);
Customer ct = new Customer(c);
p.start();
ct.start();
}
}
线程面试题
Thread和Runnable的关系,区别
Thread是类,被继承,因为类只能单继承,所以比较死板,接口的话会很灵活,实现资源共享
Thread类 new Thread类直接start() runnable接口将类的对象注入new Thread中然后start方法
Runnable代码可以被多个线程共享使用
线程池里只能放入runnable或者callable的线程,不能方入Thread类的线程
谈谈synchronized,底层
synchronized 就是使用 monitorenter 和 monitorexit 这两个指令来实现
根据JVM规范的要求,在执行 monitorenter 指令的时候,首先要去尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,就把锁的计数器加1,相应地,在执行 monitorexit 的时候会把计数器减 1,当计数器减小为 0 时,锁就释放了。
java对象头
对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充
Java 对象头是实现 synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里。它是轻量级锁和偏向锁的关键
解决多线程访问资源的同步问题,被修饰的方法或代码块任意时刻只能被一个线程执行
监视器锁monitor依赖操作系统的Mutex Lock实现,线程是映射待操作系统的原生线程上,唤醒和挂起线程都需要操作系统完成,实现线程切换需要从用户态到内核态
java6 对synchronized优化,自旋锁,适应性自旋锁,锁消除,锁粗化,偏向锁,轻量级锁减少锁操作的开销
修饰实例方法:获得当前实例的锁
synchronized: 修饰方方法是静态的,取得的锁是对类的,类中的所有对象是同一把锁, 因为静态成员不属于任何一个实例对象,是类成员。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
修饰代码块
锁升级
- 偏向锁:一段同步代码一直被一个线程访问,那么线程会自动获取锁,降低获取锁的代价
- 轻量级锁:锁为偏向锁被另一个线程访问,会转为轻量级锁,这个线程会通过自旋方式尝试获得锁
- 重量级锁: 锁为轻量级锁时,自旋到一定次数没获得锁,进入阻塞,升级为重量级锁,会使其他线程阻塞,性能降低
JVM一般是这样使用锁和Mark Word的:
1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
synchronized和ReentrantLock/Lock有什么区别呢?
ReentrantLock和synchronized都是独占锁,可重入锁,悲观锁
synchronized:
- java内置关键字
- 无法判断是否获取锁的状态,只能是非公平锁!
- 加锁解锁的过程是隐式的,用户不用手动操作,优点是操作简单但显得不够灵活
- 一般并发场景使用足够、可以放在被递归执行的方法上,且不用担心线程最后能否正确释放锁
ReentrantLock:
- 是个Lock接口的实现类
- 可以判断是否获取到锁,可以为公平锁也可以是非公平锁(默认)
- 需要手动加锁和解锁,且解锁的操作尽量要放在finally代码块中,保证线程正确释放锁
- 创建的时候通过传进参数true 创建公平锁,如果传入的是false或没传参数则创建的是非公平锁
- 底层是AQS的 state 和 FIFO 队列来控制加锁。
什么是AQS 抽象队列同步器
作用: 加锁会导致阻塞,有阻塞就有排队,实现排队必然需要某种形式的队列进行管理
原理: AQS中 维护了一个volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列)。
这里volatile能够保证多线程下的可见性,当state = 1则代表当前对象锁已经被占有,其他线程来加锁时则会失败,加锁失败的线程会被放入一个 FIFO的等待队列中,并会被 UNSAFE.park() 操作挂起,等待其他获取锁的线程释放锁才能够被唤醒。
另外state的操作都是通过CAS来保证其并发修改的安全性。
使用的 ReentrantLock 非公平锁,线程进来直接利用CAS
尝试抢占锁,如果抢占成功state
值回被改为 1,且设置独占锁线程对象为当前线程。
线程一抢占锁成功后,state
变为 1,线程二通过CAS
修改state
变量必然会失败。此时AQS
中FIFO
(First In First Out 先进先出)队列中。
synchronized 关键字和 volatile 关键字的区别
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
- volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
AtomicInteger底层实现原理是什么? 包证原子性
CAS+volatile 可见性,有序性
比较并交换 无锁实现 乐观锁 自旋锁
AtomicInteger.getAndIncrement() == num++
底层是unsafe.getAndInt() 自旋锁
CountDownLatch(常问)
CountDownLatch是JDK提供的一个同步工具,它可以让一个或多个线程等待,一直等到其他线程中执行完成一组操作。
CountDownLatch有哪些常用的方法?
有countDown
方法和await
方法,CountDownLatch在初始化时,需要指定用给定一个整数作为计数器。
当调用countDown
方法时,计数器会被减1;当调用await
方法时,如果计数器大于0时,线程会被阻塞,一直到计数器被countDown
方法减到0时,线程才会继续执行。
调用countDown
方法时,线程也会阻塞嘛
不会的,调用countDown
的线程可以继续执行,不需要等待计数器被减到0,只是调用await方法的线程需要等待。
举一个使用CountDownLatch的例子
比如张三、李四和王五几个人约好去饭店一起去吃饭,这几个人都是比较绅士,要等到所有人都到齐以后才让服务员上菜。这种场景就可以用到CountDownLatch。
可以使用await
方法的另一个重载,传入等待的超时时间,比如服务员只等3秒钟,可以把服务员类中的
latch.await(3, TimeUnit.SECONDS);
CountDownLatch的实现原理是什么?
CountDownLatch有一个内部类叫做Sync,它继承了AbstractQueuedSynchronizer类,其中维护了一个整数state
,并且保证了修改state
的可见性和原子性。
在 countDown
方法中,只调用了Sync实例的releaseShared
方法,其中的releaseShared
方法,先对计数器进行减1操作,如果减1后的计数器为0,唤醒被await方法阻塞的所有线程
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) { //对计数器进行减一操作
doReleaseShared();//如果计数器为0,唤醒被await方法阻塞的所有线程
return true;
}
return false;
}
其中的tryReleaseShared
方法,先获取当前计数器的值,如果计数器为0时,就直接返回;如果不为0时,使用CAS方法对计数器进行减1操作
protected boolean tryReleaseShared(int releases) {
for (;;) {//死循环,如果CAS操作失败就会不断继续尝试。
int c = getState();//获取当前计数器的值。
if (c == 0)// 计数器为0时,就直接返回。
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))// 使用CAS方法对计数器进行减1操作
return nextc == 0;//如果操作成功,返回计数器是否为0
}
}
在await
方法中,只调用了Sync实例的acquireSharedInterruptibly
方法
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
其中acquireSharedInterruptibly
方法,判断计数器是否为0,如果不为0则阻塞当前线程
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//判断计数器是否为0
doAcquireSharedInterruptibly(arg);//如果不为0则阻塞当前线程
}
其中tryAcquireShared
方法,是AbstractQueuedSynchronizer中的一个模板方法,其具体实现在Sync类中,其主要是判断计数器是否为零,如果为零则返回1,如果不为零则返回-1
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
ThreadLocal的底层原理
包证每个线程中会存储一份变量 ThreadLocalMap 容量为 16。
ThreadLocal 可以看作是一个map集合,key就是当前线程,value就是要存放的变量。
ThreadLocal 对象可以给每个线程分配一份属于自己的局部变量副本,多个线程之间可以互不干扰。一般我们会重写 initalValue()
方法来给当前 ThreadLocal 对象赋初始值。
每个线程 Thread 都有一份属于自己的 ThreadLoacalMap 用于存储数据。
可能会造成内存泄漏 键是弱引用,值是强引用
解决:使用完后,手动删除
ThreadLocalMap 的 扩容阈值是多少?它的扩容机制是怎样的?
首先,ThreadLocalMap 的扩容阈值为初始容量的 2/3,当数组中,存储 Entry 节点的个数大于等于 2/3 时,会它并不会直接开始扩容。
而是先调用 rehash()方法,在该方法中,全面扫描整个数组,并将数组中过期的数据(key == null)给清理掉,重新整理数组。
如果重新整理数组,并将过期的数据清理后,再次重新判断数组内的 Entry 节点的个数是否达到扩容阈值的3/4,如果达到再调用真正扩容的方法resize();
简单描述一下JDK1.8中ThreadLocal原理
- JDK8 中,每个线程对象 Thread 类内部都有一个成员属性 threadLocals(即ThreadLocalMap,它是一个Entry[]数组,而不是 Map 集合哦~),各个线程在调用同一个 ThreadLocal 对象的set(value)方法设置值的时候,就是往各自的 ThreadLocalMap 对象数组中新增值。
- ThreadLocalMap (Entry[]数组)中存放的是一个个的 Entry节点,它有两个属性字段,弱引用 key(ThreadLocal对象) ,和强引用 value (当前线程变量副本的值)。
ThreadLocal是怎样坐到线程互不干扰的呢(线程隔离)?
首先,每个线程 Thread 都有一份属于自己的 ThreadLoacalMap 用于存储数据。
当线程访问某个 ThreadLocal 对象的 get()方法时,方法内部会检测该线程的 ThreadLoacalMap 数组(Entry[])内是否存在 key 为当前 ThreadLocal 对象的 Entry 节点。如果数组内没有对应的节点,那么当前 ThreadLocal 对象就会调用其内部的 initialValue() 方法创建一个 Entry 节点存放到 ThreadLocalMap 中去。
为什么 ThreadLocalMap 选择去重新设计"Map",而不直接使用 JDK中的 HashMap呢?
因为 ThreadLocal 自己重新设计的 Map,它可以把自己的 Key 限定为特有类型(ThreadLocal),这个特定类型的Key 使用的是弱引用 WeakReference<ThreadLocal<?>>
,而 HashMap 中的 Key 采用的是强引用方式。
ThreadLocalMap的Enrty的key为什么要设置成弱引用
ThreadLocalMap 存储的格式是 Entry<ThreadLocal, T>。如果使用强引用,当 key 原来对象失效的时候,jvm不会回收 map 里面的 ThreadLocal。
ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用引用他,那么系统 GC 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value。
站在 ThreadLocalMap 角度就可以区分出哪些 Entry 是过期的,哪些 Entry 是非过期的。
例如:在set()方法向下寻找可用 solt 桶位的过程中,如果碰到key == null 的情况,说明当前Entry 是过期数据,这个时候可以强行占用该桶位,通过replaceStaleEntry方法执行替换过期数据的逻辑。
例如:cleanSomeSlots(int i, int n)方法通过遍历桶位,也会将 key == null 过期数据清理掉。
请你说一下 ThreadLocal 的 get 方法的执行流程
① 首先 get() 方法中会先获取当前线程对象 t : Thread t = Thread.currentThread();
② 接下来根据 t 获取其独有的 ThreadLocalMap 数组:ThreadLocalMap map = getMap(t);
③ 如果 ② 获取的 map为空,则调用setInitialValue()方法,该方法内部调用 initialValue();方法获取 value,并根据 当前线程t 和 value 调用 createMap(t, value); 方法创建 ThradLocalMap。
④ 如果 ② 获取的 map不为空,则直接调用 ThreadLocalMap.Entry e = map.getEntry(this); 方法通过 this(当前ThreadLocal对象)从 ThreadLocalMap 中获取对应封装数据的 Entry 节点。
⑤ 最终通过 T result = (T)e.value; 得到要获取的线程变量副本的值。
请你说一下 ThreadLocal 的 set 方法的执行流程?
① 首先,set()方法向 ThreadLocalMap 中添加数据时,也是需要根据 Key (ThreadLocal对象) 的去寻址找到要插入的桶位下标 i = key.threadLocalHashCode & (len-1);
② 根据桶位下标,获取对应桶中的Enety 对象Entry e = tab[i];,如果获取的 e 为 null ,则说明是空桶,直接讲 Key 和 Value 包装成 Entry 放入桶中即可:tab[i] = new Entry(key, value);
③ 如果第 ② 步骤获取的 e 不为 null,说明不是空桶,则需要从以下三种情况考虑:
- 如果当前桶中 Entry 的 Key 不是当前 ThreadLocal 对象,且不为 null,则调用nextIndex(int i, int len)方法线性查找下一个空桶位,并将新数据放入。
- 如果当前桶中 Entry 的 Key 是当前 ThreadLocal 对象,则通过更新操作,将就 Entry 的 Value 值覆盖。
- 如果如果当前桶中 Entry 的 Key 是null,则说明当前 Entry 已经过期,需要执行 替换过期数据的逻辑: replaceStaleEntry(key, value, i);。
什么是CAS
如果期望达到了,那么就更新
比较当前工作内存的值和主内存中的值,如果这个值是期望的,那么久执行操作 ,如果不是就一直循环
缺点:1. 循环耗时
- ABA问题
ABA问题
- 假设内存有一个值为A的变量,存储在地址V中
- 有3个线程使用CAS方式更新,由于时间差的问题,线程1,2获得值,3为获得值
- 线程1执行成功将A更新为B,线程2因为阻塞,没做操作,线程3在线程1更新后获得值
线程1 获得A 更新为B
线程2 获得A 期望改为B 阻塞中
线程3 获得B 期望改为A
- 线程2继续阻塞,线程3开始执行,将B更新为A
线程1 获得A 更新为B 已返回
线程2 获得A 期望改为B 阻塞中
线程3 获得B 更新改为A
- 线程2恢复,经过compare检测,内存地址V等于A,于是将A改为B
线程1 获得A 更新为B 已返回
线程2 获得线程3更新后的值A 更新为B
线程3 获得B 更新改为A 已返回
解决ABA问题
加版本号(AtomicStampedReference)
公平锁和非公平锁
先来先服务
非顺序获取锁,可能会出现饥饿和优先级反转(synchronized)
可重入锁和不可重入锁
可重入锁,递归锁,不会发生死锁,线程获取锁后再尝试获取锁时会自动获取锁
不可重入锁,若线程已有锁,在获取时会阻塞
独占锁和共享锁
独享锁,是指锁一次只能被一个线程持有。
也叫X锁/排它锁/写锁/独享锁:该锁每一次只能被一个线程所持有,加锁后任何线程试图再次加锁的线程会被阻塞,直到当前线程解锁。例子:如果 线程A 对 data1 加上排他锁后,则其他线程不能再对 data1 加任何类型的锁,获得独享锁的线程即能读数据又能修改数据!
共享锁,是指锁一次可以被多个线程持有。
也叫S锁/读锁,能查看数据,但无法修改和删除数据的一种锁,加锁后其它用户可以并发读取、查询数据,但不能修改,增加,删除数据,该锁可被多个线程所持有,用于资源数据共享!
自旋锁 循环尝试获取锁,减少上下文切换,缺点耗时
不会发生线程状态的切换,一直处于用户态,减少线程上下文的切换
线程池的好处,如何创建线程池?
Executors 目前提供了 5 种不同的
线程池创建配置:
1)newCachedThreadPool():用来处理大量短时间工作任务的线程池。当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;其内部使用 SynchronousQueue 作为工作队列。
2)newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。
3)newSingleThreadExecutor(),它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态
4)newSingleThreadScheduledExecutor() 和 newScheduledThreadPool(int corePoolSize),创建的是个 ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。
5)newWorkStealingPool(int parallelism),Java 8 才加入这个创建方法,并行地处理任务,不保证处理顺序。
线程池运行流程
阿里编程规约建议使用ThreadPoolExecutor类,是最原始的线程池创建.
7个参数
核心线程池数量corePoolSize
最大线程池数量maximumPoolSize
非核心线程池的线程空闲时间keepAliveTime
时间单位unit
等待队列 Array 有界 容量100 Linked(有界 无界) workQueue
创建线程工程 threadFactory
Handler 决绝策略
4种拒绝策略
等待队列满了,线程最大数量
AbortPolicy 报错
DiscardPolicy 直接丢弃
DiscardOldestPolicy 抛弃等待时间最长的任务
CallerRunsPolicy 调用者线程(运行提交任务的线程)
阻塞队列
底层还需要自己去深入了解
- ArrayBlockingQueue底层是基于数组的,是一个有界缓存等待队列。
- LinkedBlockingQueue底层基于链表,是一个无界缓存等待队列。
- SynchronousQueue没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。 前两者差别不大,第三个比较特殊。
- SynchronousQueue是这样一种阻塞队列,其中每个 put 必须等待一个 take,反之亦然。同步队列没有任何内部容量,甚至连一个队列的容量都没有。 也就是说这更像是一种管道,资源从一个方向快速传递到另一方向
阻塞队列优点
- 降低多线程开发的难度
- 隔离代码,实现业务代码解耦