1 并发编程基础
1.1 并行、并发、串行
- 并发:多个任务在同一个 CPU 核上,按细分的时间片轮流 (交替)执行,从逻辑上来看那些任务是同时执行。
- 并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的同时进行。
- 串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
1.2 线程和进程的区别
- 进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立单位
- 线程是进程的一个实体,一个进程中一般拥有多个线程。线程之间共享地址空间和其它资源(所以通信和同步等操作,线程比进程更加容易)
- 线程一般不拥有系统资源,但是也有一些必不可少的资源(使用ThreadLocal存储)
线程上下文的切换比进程上下文切换要快很多。进程切换时,涉及到当前进程的CPU环境的保存和新被调度运行进程的CPU环境的设置。线程切换时,仅需要保存和设置少量的寄存器内容,不涉及存储管理方面的操作。
1.3 并发编程的优缺点
优点
-
充分利用多核CPU的计算能力:通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升方便进行业务拆分。
-
提升系统并发能力和性能:现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。
缺点
并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、线程安全、死锁等问题。 -
内存泄漏例如程序发生的oom异常
-
上下文切换例如自旋锁中线程比较时需要一直自旋等待,cpu切换消耗资源。
-
线程安全例如很多线程不安全的类hashmap、arraylist等,juc提供了一些解决线程安全的类。
-
死锁
1.4 java线程的创建方式
- 实现 Runable接口
- 继承Thread类
- 基于线程池方式实现ExecutorService—实现Callable,有返回值
- 基于线程池方式实现ExecutorService—threadPool.execute
1.5 线程状态及状态之间的转换
插入图片解释…
- synchronized: 一直持有锁,直至执行结束
- wait():使一个线程处于等待状态,并且释放所持有的对象的锁,需捕获异常。
- sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,需捕获异常,不释放锁。
- sleep():方法是Thread类中方法,而wait()方法是Object类中的方法。
sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态,在调用sleep()方法的过程中,线程不会释放对象锁。而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备。 - notify():唤醒一个处于等待状态的线程,注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且不是按优先级。
- notityAll():唤醒所有处入等待状态的线程,注意并不是给所有唤醒线程一个对象的锁,而是让它们竞争。
- yiled():方法的作用是:让当前处于运行状态的线程退回到可运行状态,让出抢占资源的机会
1.5.1 线程的物种状态(生命周期)
新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)
- 新建状态(new):new 一个线程后,线程就处于新建状态,此时由 JVM 分配内存并初始化成员变量的值;
- 就绪(runnable):线程对象调用了 start 方法后处于就绪状态,JVM 会为其创建方法调用栈和程序计数器,等待程序调用运行;
- 运行状态(running):处于就绪状态的线程获得了 CPU 使用权,开始执行 run 方法的线程执行体,则改线程处于运行状态;
- 线程阻塞状态(Blocked):阻塞是指线程让出了 cpu 使用权,即让出了 cpu timeslice(时间片),暂时停止运行直到线程进入可运行(runnable)状态,才能有机会获得 cpu timeslice 并进入运行(running)状态;
阻塞情况分为三种:
- 等待阻塞(o.wait->等待队列):运行的线程执行 o.wait()方法,JVM 会把线程放入等待队(waiting queue)列中;
- 同步阻塞(lock->锁池):运行的线程在获取对象的同步锁时,该同步锁被别的线程占用,则 JVM 会把该线程放入锁池(lock pool) 中 ;
- 其它阻塞(sleep/join):运行的线程调用了 Thread.sleep(long ms)或 t.join()方法、或发出了 IO 请求时,JVM 会把线程置为阻塞 状态,直到 sleep()超时、join 等待的线程终止或超时、IO 处理完毕才可进入运行(running)状态;
- 线程死亡(Dead):
- 正常结束:run()或 call()方法执行完成,线程正常结束;
- 异常结束:线程抛出未捕获异常或 ERROR;
- 调用 stop:调用 stop()方法结束线程,但该方法容易导致死锁,不会推荐使用;
1.5.2 终止线程的4种方式
-
正常运行结束
程序运行结束,线程也自动结束; -
程序使用标志退出线程
在某些情况下,线程需要满足一定条件下才能够结束,这里一般通过使用标志的方式来判断任务是否退出,例如countdownlanch -
Interrupt方法结束线程
在阻塞、非阻塞状态下调用线程的 interrupt()方法的处理方式也不同;
阻塞状态:在 sleep 或 socket.accept 等方法都会让线程进入阻塞状态,此时调用 interrupt 方法会抛出 InterruptException 异常,阻塞中的方法抛出异常后需要使用代码捕获异常并 break 跳出循环状态;
非阻塞状态:调用 interrupt 方法后,中断标志位会为 true,此时循环可以通过判断 isInterrupted()方法来判断是否中断,然后退出循环;
interrupt()、interrupted()和isInterrupted()的区别和作用
public void interrupt()
其作用是中断此线程(此线程不一定是当前线程,而是指调用该方法的Thread实例所代表的线程),但实际上只是给线程设置一个中断标志,线程仍会继续运行。
public static boolean interrupted()
作用是测试当前线程是否被中断(检查中断标志),返回一个boolean并清除中断状态,第二次再调用时中断状态已经被清除,将返回一个false。
public boolean isInterrupted()
作用是只测试此线程是否被中断 ,不清除中断状态。
- stop方法终止线程(线程不安全)
使用 thread.stop()来强行终止线程,但是它是危险的,因为在调用 thread.stop()后,创建子线程的线程会抛出 ThreadDeadError 错误,且子线程所持有的锁都会被释放。而一般锁适用于数据安全,如果锁被释放了可能导致数据不同步,也就是数据不是线程安全的了;
1.5.3 sleep 和 wait 的区别
- 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
- 是否释放锁:sleep() 不释放锁;wait() 释放锁。
- 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
1.5.4 start 和 run 的区别
- start 是用来启动线程让线程进入就绪状态,创建方法调用栈、程序计数器,等待获得 cpu 使用权;
- run 是线程执行的程序,此时线程进入运行状态,run 结束则线程结束,让出 cpu 使用权;
1.5.5 java守护进程
守护线程是为用户线程提供公共服务的线程,优先级比较低,主要用于为系统的其它对象或线程提供服务 通过设置 setDaemon(true)将线程设置为守护线程,首先线程创建的子线程也是守护线程,守护线程是 jvm 级别的,只有当 jvm 停止的 时候才会停止 比如垃圾收回收线程就是一个经典守护线程,当程序中没有任何的线程,程序就没有垃圾,垃圾回收器就没事干就会自动离开 当 jvm 中所有的线程都是守护线程的时候,jvm 就可以退出了,否则 JVM 就不会退出。
2 JMM(java内存模型)
JMM是java memory model(Java内存模型)
如果不存在内存模型的概念,运行的结果依赖于处理器,不同的处理器结果不同,无法保证并发安全。所以需要一个标准,让多程序运行的结果可预期。
JMM是一种规范,需要各个JVM的实现来遵守JMM规范,以便开发者可以利用这些规范,更方便地开发多线程程序。(JVM有多种实现,有oracle,有openjd的)。
JMM可能带来可⻅性、原⼦性和有序性问题
所谓可⻅性,就是某个线程对主内存内容的更改,应该⽴刻通知到其它线程。
所谓原⼦性,是指⼀个操作是不可分割的,不能执⾏到⼀半,就不执⾏了。
所谓有序性,就是指令是有序的,不会被重排。
2.1 原子性
原⼦性指的是什么意思?
不可分割,完整性,也即某个线程正则做某个具体业务时,中间不可以被加塞或者被分割。需要整体完整,要么同时成功,要么同时失败。
jmm中原子性带来的问题:
class MyData{
//int number=0;
volatile int number=0;
//此时number前⾯已经加了volatile,但是不保证原⼦性
public void addPlusPlus(){
number++;
}
public class VolatileDemo {
public static void main(String[] args) {
atomicDemo();
}
private static void atomicDemo() {
System.out.println("原⼦性测试");
MyData myData=new MyData();
for (int i = 1; i <= 20; i++) {
new Thread(()->{
for (int j = 0; j <1000 ; j++) {
myData.addPlusPlus();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t int类型最终number值: "+myData.number);
}
volatile并不能保证操作的原⼦性。这是因为,⽐如⼀条number++的操作,会形成3条字节码指令。
2: getfield #2 // Field number:I //读
5: iconst_1 //++常量1
6: iadd //加操作
假设有3个线程,分别执⾏number++,都先从主内存中拿到最开始的值,number=0,然后三个线程分别进⾏操作。假设线程0执⾏完毕,number=1,也⽴刻通知到了其它线程,但是此时线程1、2已经拿到了number=0,所以结果就是写覆盖,线程1、2将number变成1。
原子性的解决方案:
对 addPlusPlus() ⽅法加锁。
使⽤ java.util.concurrent.AtomicInteger类。
2.2 可见性
可见性带来的问题:
class MyData {
int number = 0;
//volatile int number = 0;
public void setTo60() {
this.number = 60;
}
}
public class VolatileDemo {
public static void main(String[] args) {
volatileVisibilityDemo();
}
//volatile可以保证可⻅性,及时通知其它线程主物理内存的值已被修改
private static void volatileVisibilityDemo() {
System.out.println("可⻅性测试");
MyData myData = new MyData();//资源类
//启动⼀个线程操作共享数据
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 执 ⾏");
try {
TimeUnit.SECONDS.sleep(3);
myData.setTo60();
System.out.println(Thread.currentThread().getName() + "\t更新number值: " + myData.number);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "ThreadA").start();
while (myData.number == 0) {
//main线程持有共享数据的拷⻉,⼀直为0
}
System.out.println(Thread.currentThread().getName() + "\t main获取number值: " + myData.number);
}
}
虽然⼀个线程把number修改成了60,但是main线程持有的仍然是最开始的0,所以⼀直循环,程序不会结束。
为什么会有可见性问题
这是因为CPU有多级缓存,导致读的数据过期
如果所有核心都只用一个缓存,就没有内存可见性问题。
每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些核心读取的值是一个过期的值。
Java作为高级语言,屏蔽了底层的实现细节。用JMM定义了一套读写内存数据的规范,使我们不再需要关心一级缓存、二级缓存、三级缓存这些问题。但是JMM抽象出了主内存和本地内存的概念。这里说的本地内存并不是真的是一块给每个线程分配的内存,而是JMM的一个抽象,是对寄存器、一级缓存、二级缓存等的抽象。
插入图片解释…
一个线程(就是一个核心)和自己的工作内存沟通,不同的线程工作内存是不互通的。线程通过buffer和主内存沟通。线程间的交互也只能通过主内存进行。
总结:
1)所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝。
2)线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中。
3)主内存是多个线程共享的,但线程间不共享工作内存。如果线程间需要通信,必须借助主内存中转来完成。
所有的共享变量存在于主内存中,每个线程都有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致可见性问题。
可见性问题的解决:
添加了volatile修饰
2.3 有序性
计算机在执⾏程序时,为了提⾼性能,编译器和处理器常常会对指令做重排,⼀般分以下三种:
其中所谓指令重排序,就是出于优化考虑,CPU执⾏指令的顺序跟程序员⾃⼰编写的顺序不⼀致。就好⽐⼀份试卷,题号是⽼师规定的,是程序员规定的,但是考⽣(CPU)可以先做选择,也可以先做填空。
jmm有序性带来的问题:
在多线程场景下,说出最终值a的结果是多少? 5或者6
public class ResortSeqDemo {
int a=0;
boolean flag=false;
/*
多线程下flag=true可能先执⾏,还没⾛到a=1就被挂起。
其它线程进⼊method02的判断,修改a的值=5,⽽不是6。
*/
public void method01(){
a=1;
flag=true;
}
public void method02(){
if (flag){
a+=5;
System.out.println("*****最终值a: "+a);
}
}
public static void main(String[] args) {
ResortSeqDemo resortSeq = new ResortSeqDemo();
new Thread(()->{resortSeq.method01();},"ThreadA").start();
new Thread(()->{resortSeq.method02();},"ThreadB").start();
}
}
有序性问题解决:
采⽤ volatile 可实现禁⽌指令重排优化
2.4 volatile关键字
volatile 关键字是Java提供的⼀种轻量级同步机制。
- 它能够保证可⻅性和有序性
- 但是不能保证原⼦性
- 禁⽌指令重排
2.4.1 为什么volatile可实现禁⽌指令重排优化
我们先来了解⼀个概念,内存屏障(Memory Barrier)⼜称内存栅栏,是⼀个CPU指令,volatile底层就是⽤CPU的内存屏障(Memory Barrier)指令来实现的,它有两个作⽤:
- ⼀个是保证特定操作的顺序性
- ⼆是保证变量的可⻅性。
由于编译器和处理器都能够执⾏指令重排优化。所以,如果在指令间插⼊⼀条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插⼊内存屏障可以禁⽌在内存屏障前后的指令进⾏重排序优化。内存屏障另外⼀个作⽤是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读到这些数据的最新版本。
2.4.2 Volatile和synchronized的关系
volatile可以看成是轻量版的synchronized
如果一个共享变量自始至终只被各个线程赋值,而没有其它操作,则可以用volatile代替synchronized,因为赋值自身是有原子性的,而volatile又保证了可见性,所以可以保证线程安全。
Volatile属性的读写操作是无锁的,但它不能替代synchronized,因为它没有提供原子性。因为无锁,不需要花费时间在获取锁和释放锁上,所以它低成本。
2.4.3 双重检查锁单例模式中的volatile
public class SingletonDemo {
private static volatile SingletonDemo singletonDemo=null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() +"\t SingletonDemo构造⽅法执⾏了");
}
public static SingletonDemo getInstance(){
if (instance == null) {
synchronized (SingletonDemo.class){
if (instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
//多线程操作
for (int i = 0; i < 10; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},Thread.currentThread().getName()).start();
}
}
}
instance=new SingletonDemo(); 可以⼤致分为三步:
// 底层Java Native Interface中的C语⾔代码内容,开辟空间的步骤
memory = allocate(); //步骤1.分配对象内存空间
instance(memory); //步骤2.初始化对象
instance = memory; //步骤3.设置instance指向刚分配的内存地址,此时instance!= null
如果不用volatile关键字,就有可能发生这三步的指令重排序,如果在多线程环境下,假设线程 A 在执行创建对象时,(2)和(3)进行了重排序,如果线程 B 在线程 A 执行(3)时拿到了引用地址,并在第一个检查中判断 singleton != null 了,但此时线程 B 拿到的不是一个完整的对象,在使用对象进行操作时就会出现问题。所以,这里使用 volatile 修饰 singleton 变量,就是为了禁止在实例化对象时进行指令重排序。
3 CAS
- CAS(compare and swap),主要用在并发场景中,是一种思想,是一种实现线程安全的算法。在并发编程中实现那些不能被打断的交换操作,从而避免在多线程下执行顺序不确定导致错误。
- CAS 包含 3 个参数,更新对象、旧值、新值,在更新的时候会比较当前值是否为旧值,为旧值则更新为新值,否则说明已经有其它线程做了更新,当前线程什么也不做,返回当前变量的真实值。
- CAS 是乐观锁,总是认为可以成功的完成操作,当多个线程 CAS 操作同一个变量时,只有一个线程会成功,其它线程失败,允许再次尝试 或放弃操作。
3.1 原子包 java.util.concurrent.atomic
查看 AtomicInteger.getAndIncrement() ⽅法,发现其没有加 synchronized 也实现了同步。
调⽤UnSafe类中的CAS⽅法,JVM会帮我们实现出CAS汇编指令。CAS是⼀种系统原语,原语属于操作系统⽤语范畴,是由若⼲条指令组成的,⽤于完成某个功能的⼀个过程,并且原语的执⾏是连续的,在执⾏过程中不允许被中断,也就是说CAS是⼀条CPU的原⼦指令,不会造成所谓的数据不⼀致问题
3.2 CAS的缺点:
- ⼀直循环,开销⽐较⼤。我们可以看到getAndAddInt⽅法执⾏时,有个do while,如果CAS失败,会⼀直进⾏尝试。如果CAS⻓时间⼀直不成功,可能会给CPU带来很⼤的开销。
- 对⼀个共享变量执⾏操作时,我们可以使⽤循环CAS的⽅式来保证原⼦操作,但是,对多个共享变量操作时,循环CAS就⽆法保证操作的原⼦性,这个时候就可以⽤锁来保证原⼦性。
- ABA问题。
3.3 原子引用
可以⽤ AtomicReference 来包装这个POJO,使其操作原⼦化。
public class AtomicReferenceDemo {
public static void main(String[] args) {
User user1 = new User("Jack",25);
User user2 = new User("Tom",21);
AtomicReference<User> atomicReference = new AtomicReference<>();
atomicReference.set(user1);
System.out.println(atomicReference.compareAndSet(user1,user2)+"\t"+atomicReference.get()); // true
System.out.println(atomicReference.compareAndSet(user1,user2)+"\t"+atomicReference.get()); //false
}
}
3.4 ABA问题
CAS 缺点就是会导致 ABA 问题,CAS 算法需要从内存中取出变量,然后比较并替换,在这个期间可能有其它线程修改了变量,比如是变量 A1,另一个线程修改为 A2 后,又将修改为 A==1,这是去比较并更新成功了,但实际上变量 A 已经被修改了。
但是有的需求,还看重过程,中间不能发⽣任何修改。
3.5 ABA问题的解决(AtomicStampedReference类似于时间戳)
使⽤ AtomicStampedReference 类可以解决ABA问题。这个类维护了⼀个“版本号”Stamp,在进⾏CAS操作的时候,不仅要⽐较当前值,还要⽐较版本号。只有两者都相等,才执⾏更新操作。
参数说明:
V expectedReference, 预期值引⽤
V newReference, 新值引⽤
int expectedStamp, 预期值时间戳
int newStamp, 新值时间戳
4 并发容器
ConcurrentHashMap:线程安全的HashMap
CopyOnWriteArraySet:线程安全的hashset
CopyOnWriteArrayList:线程安全的List
ConcurrentLinkedQueue:高效的非阻塞并发队列,使用链表实现。可以看做一个线程安全的LinkedList
4.1 CopyOnWriteArrayList
ArrayList 不是线程安全类,在多线程同时写的情况下,会抛出java.util.ConcurrentModificationException 异常。
解决⽅法:
- 使⽤ Vector ( ArrayList 所有⽅法加 synchronized ,太重)。
- 使⽤ Collections.synchronizedList() 转换成线程安全类。
- 使⽤ java.concurrent.CopyOnWriteArrayList (推荐)。
JUC中提供了CopyOnWriteArrayList 通过写时复制来实现读写分离。⽐如其 add() ⽅法,就是先复制⼀个新数组,⻓度为原数组⻓度+1,然后将新数组最后⼀个元素设为添加的元素。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
//得到旧数组
Object[] elements = getArray();
int len = elements.length;
//复制新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
//设置新元素
newElements[len] = e;
//设置新数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
4.2 CopyOnWriteArraySet
HashSet 底层是⽤ HashMap 实现的。既然是⽤ HashMap 实现的,那 HashMap.put() 需要传两个参数,⽽ HashSet.add() 只传⼀个参数,这是为什么?实际上 HashSet.add() 就是调⽤的HashMap.put() ,只不过Value被写死了,是⼀个 private static final Object 对象。跟List类似, HashSet 和 TreeSet 都不是线程安全的,与之对应的有 CopyOnWriteSet 这个线程安全类。这个类底层维护了⼀个 CopyOnWriteArrayList 数组。
private final CopyOnWriteArrayList<E> al;
public CopyOnWriteArraySet() {
al = new CopyOnWriteArrayList<E>();
}
4.3 ConcurrentHashMap(重点)
面试题 :concurrenthashmap原理,put,get,size,扩容,怎么保证线程安全的,1.7和1.8的区别,为什么用synchronized,分段锁有什么问题,hash算法做了哪些优化
4.3.1 JDK1.7中的原理和实现
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment实际继承自可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,每个Segment里包含一个HashEntry数组,我们称之为table,每个HashEntry是一个链表结构的元素。
ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,只要多个修改操作发生在不同的段上,它们就可以并发进行。
ConcurrentHashMap():
public ConcurrentHashMap() {
// 这三个值分别表示初始容量(16),负载因子(0.75),并发度(16)
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
// 下面这段代码是根据传进来的初始容量,计算出一个2^n的合适容量来初始化segments,并不是按照进来的大小来初始化
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
// 这面这算代码是初始化segments, 此处只初始化segment[0],其他结点的初始化是在put操作的时候
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
三个参数:
**initialCapacity:**初始容量大小 ,默认16。
**loadFactor:*扩容因子,默认0.75,当一个Segment存储的元素数量大于initialCapacity loadFactor时,该Segment会进行一次扩容。
**concurrencyLevel :**并发度,默认16。并发度可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。
构造方法说明:
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
保证Segment数组的大小,一定为2的幂
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
保证每个Segment中tabel数组的大小,一定为2的幂,初始化的三个参数取默认值时,table数组大小为2
// create segments and segments[0]
// 这面这算代码是初始化segments, 此处只初始化segment[0],其他结点的初始化是在put操作的时候
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
始化segments, 此处只初始化segment[0],其他结点的初始化是在put操作的时候
hash():
private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
ConcurrentHashMap采用重哈希的方式 :只是对 Segments对象中的HashEntry数组进行重哈希。为了减少hash冲突,让元素尽量均匀的分布在segment上面,从而提高容器的存储效率,如果没有再散列,就会出现大量元素存储到同一个segment,失去了分段锁的意义。
get():
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());/*计算hash值*/
/*根据hash值确定节点位置*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
/*Node数组中的节点就是要找的节点*/
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
/*eh<0 说明这个节点在树上 调用树的find方法寻找*/
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
/*到这一步说明是个链表,遍历链表找到对应的值并返回*/
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
定位segment:取得key的hashcode值进行一次再散列(通过Wang/Jenkins算法),拿到再散列值后,以再散列值的高位进行取模得到当前元素在哪个segment上。
定位table:同样是取得key的再散列值以后,用再散列值的全部和table的长度进行取模,得到当前元素在table的哪个元素上。
定位segment和定位table后,依次扫描这个table元素下的的链表,要么找到元素,要么返回null。
用于存储键值对数据的HashEntry,在设计上它的成员变量value等都是volatile类型的,这样就保证别的线程对value值的修改,get方法可以马上看到。
put():
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
// 上面是先在segments数组中找到Segment,下面才是在Segment中加锁put
return s.put(key, hash, value, false);
}
// Segment类中的方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
// 下面的try里面的代码就是上面第一行加锁成功之后才会往下走的,所有会在finally中进行unlock
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
1、首先确定段的位置,然后调用Segment中的put方法:
2、加锁
3、检查当前Segment数组中包含的HashEntry节点的个数,如果超过阈值就重新hash
4、然后再次hash确定放的链表。
5、在对应的链表中查找是否相同节点,如果有直接覆盖,如果没有新元素作为链表的头部节点。
size():
1、首先对segments进行两次统计操作,如果两次的统计值相同,则说明这段时间没有线程来访问这个map,拿到的size大小是正确的,返回。
2、如果前两次拿到的值不相同,第三次就先对每一个segment进行加锁,然后在整体遍历计算一次,最后释放锁。所以在并发度比较高的情况之下,不建议使用这个方法,会把所有的segment都加锁。
扩容:
segment 不扩容,扩容下面的table数组,每次都是将数组翻倍(原容量左移1位)
4.3.2 JDK1.8中的原理和实现
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本
改进一: 取消segments字段,直接采用transient volatile HashEntry[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
改进二: 将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。查询的时间复杂度可以降低到O(logN)
ConcurrentHashMap():
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
初始化不做任何操作,也不会初始化table数组,在调用put()方法的时候判断table[]如果为null,进行初始化。
put():
put的过程很清晰,对当前的table进行无条件自循环直到put成功
1、如果没有初始化,就先调用initTable()方法来进行初始化过程
2、如果没有hash冲突,就直接CAS插入
3、如果还在进行扩容操作,就先进行扩容
4、如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
5、最后,如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
6、如果添加成功,就调用addCount()方法统计size,并且检查是否需要扩容
get():
ConcurrentHashMap的get操作的流程很简单,可以分为三个步骤来描述
1、计算hash值,定位到该table索引位置,如果是首节点符合就返回
2、如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
3、以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
spread():
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
ConcurrentHashMap中没有hash()方法,取而代之的是spread方法。
先对低16位进行扰动处理,然后屏蔽符号位,结果为32位int型非负数,就是进行重哈希.用来降低哈希冲突的可能性。
size():
在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大的意义的,因为size是实时在变的,只能计算某一刻的大小,但是某一刻太快了,人的感知是一个时间段,所以并不是很精确。
扩容:
扩容过程有点复杂,这里主要涉及到多线程并发扩容,ForwardingNode的作用就是支持扩容操作,将已处理的节点和空节点置为ForwardingNode,并发处理时多个线程经过ForwardingNode就表示已经遍历了,就往后遍历。
总结:
- JDK1.8取消了segment数组,直接用table保存数据,锁的粒度更小,减少并发冲突的概率。
- JDK1.8存储数据时采用了链表+红黑树的形式,纯链表的形式时间复杂度为O(n),红黑树则为O(logn),性能提升很大。
- JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
- JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
- JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据
4.3.2 常见面试题
Java 中的另一个线程安全的与 HashMap 极其类似的类是什么?同样是线程安全,它与 HashTable 在线程同步上有什么不同?
与 HashMap 极其类似的类是ConcurrentHashMap
HashTable是锁整张表,在高并发的情况下效率低一些,ConcurrentHashMap 1.7采用分段锁,1.8中采用cas+synchronized +分段锁大大提升了并发效率。
HashTable是强一致,在get时直接锁整张表,而ConcurrentHashMap 在get时是不加锁的,可能在拿的过程中数据被改,是弱一致的。
HashMap & ConcurrentHashMap 的区别?
前者线程不安全,后者线程安全
前者允许key or value值为空,后者不允许key or value值为空
在1.8中他们的红黑树TreeNode继承不同,HashMap的TreeNode继承自LinkedHashMap.Entry,而ConcurrentHashMap 的TreeNode继承自Node
ConcurrentHashMap 锁机制具体分析(JDK 1.7 VS JDK 1.8)?
1.7中底层采用数组+数组+链表结构,使用Segment和HashEntry类,而Segment继承自可重入的ReentrantLock同步锁来充当锁的角色,由每个segment保护自己所属的数据,而HashEntry所组成的一个数组又在segment下面
1.8采用node+cas+synchronized
ConcurrentHashMap 在 JDK 1.8 中,为什么要使用内置锁 synchronized 来代替重入锁 ReentrantLock?
1.开发团队在jdk1.8上对synchronized 做了大量的性能优化,而且基于虚拟机本来的关键字优化空间更大,更加自然;
2.显示锁是一个对象,要消耗内存,而synchronized 作为一个语言特性,它的内存消耗会更少
在高并发下的情况下如何保证取得的元素是最新的?
答:用于存储键值对数据的HashEntry,在设计上它的成员变量value等都是volatile类型的,这样就保证别的线程对value值的修改,get方法可以马上看到。
ConcurrentHashMap实现原理是怎么样的或者问ConcurrentHashMap如何在保证高并发下线程
安全的同时实现了性能提升?
答:ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,只要多个修改操作发生在不同的段上,它们就可以并发进行。
5 锁
5.1 synchronized
synchronized 内部锁底层实现:
进入时,执行 monitorenter,将计数器+1,释放锁 monitorexit 时,计数器-1 当一个线程判断到计数器为 0 时,则当前锁空闲,可以占用;反之,当前线程进入 等待状态。
synchronized 关键字是用来控制线程同步的,就是在多线程的环境下, 控制 synchronized 代码段不被多个线程同时执行。
synchronized 可以修饰类、方法、变量。
⼀个对象⾥⾯如果有多个synchronized⽅法,某⼀个时刻内,只要⼀个线程去调⽤其中的⼀个synchronized⽅法了,其他的线程都只能等待,换句话说,某⼀个时刻内,只能有唯⼀⼀个线程去访问这些synchronized⽅法,锁的是当前对象this,被锁定后,其他的线程都不能进⼊到当前对象的其他的synchronized⽅法。对于同步⽅法块,锁的是synchronized括号⾥配置的对象。对于静态同步⽅法,锁是当前类的class对象。
5.2 乐观锁和悲观锁
悲观锁:synchronized 和 lock 接口
乐观锁:典型例子就是原子类、并发容器等
适用场景:
悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可
以避免大量的无用自旋等消耗。典型情况:
1)临界区有 IO 操作
2)临界区代码复杂或循环量大
3)临界区竞争非常激烈
乐观锁:适合并发写入少,大部分是读取的场景,不加锁的能让读取必能大幅提高。
5.3 公平锁和非公平锁
公平锁,就是多个线程按照申请锁的顺序来获取锁,类似排队,先到先得。
⾮公平锁,则是多个线程抢夺锁,会导致优先级反转或饥饿现象。
区别:
- 公平锁在获取锁时先查看此锁维护的等待队列,为空或者当前线程是等待队列的队⾸,则直接占有锁,否则插⼊到等待队列,FIFO原则。
- ⾮公平锁⽐较粗鲁,上来直接先尝试占有锁,失败则采⽤公平锁⽅式。⾮公平锁的优点是吞吐量⽐公平锁更⼤。
synchronized 和 juc.ReentrantLock 默认都是⾮公平锁。 ReentrantLock 在构造的时候传⼊ true则是公平锁。
5.4 可重入锁
可重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护 一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为 0 时,表明该锁未被任何线程所持有,其它线程可 以竞争获取锁。
锁的配对
lock.lock();
lock.lock();
try{
someAction();
}finally{
lock.unlock();
}
锁之间要配对,加了⼏把锁,最后就得解开⼏把锁,锁的数量不匹配会导致死循环。
5.5 自旋锁
所谓⾃旋锁,就是尝试获取锁的线程不会⽴即阻塞,⽽是采⽤循环的⽅式去尝试获取。⾃⼰在那⼉⼀直循环获取,就像“⾃旋”⼀样。这样的好处是减少线程切换的上下⽂开销,缺点是会消耗CPU。CAS底层的 getAndAddInt 就是⾃旋锁思想。
/**
* 题⽬:实现⼀个⾃旋锁
* ⾃旋锁好处:循环⽐较获取直到成功为⽌,没有类似wait的阻塞。
*
* 通过CAS操作完成⾃旋锁,A线程先进来调⽤myLock⽅法⾃⼰持有锁5秒钟,
* B随后进来后发现当前有线程持有锁,不是null,所以只能通过⾃选等待,直到A释放锁后B随后
抢到。
*/
public class SpinLockDemo {
//原⼦引⽤线程
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void myLock(){
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName()+"\t"+"com in...");
while(!atomicReference.compareAndSet(null, thread)){ }
}
public void myUnLock(){
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread, null);
System.out.println(Thread.currentThread().getName()+"\t"+" unlock...");
}
public static void main(String[] args) {
SpinLockDemo spinLockDemo = new SpinLockDemo();
new Thread(()->{
spinLockDemo.myLock();
try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException
e) {e.printStackTrace(); }
spinLockDemo.myUnLock();
}, "AA").start();
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) {e.printStackTrace(); }
new Thread(()->{
spinLockDemo.myLock();
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException
e) {e.printStackTrace(); }
spinLockDemo.myUnLock();
}, "BB").start();
}
}
5.6 读写锁/独占/共享
读锁是共享的,写锁是独占的。
juc.ReentrantLock 和 synchronized 都是独占锁,独占锁就是⼀个锁只能被⼀个线程所持有。有的时候,需要读写分离,那么就要引⼊读写锁,即 juc.ReentrantReadWriteLock 。
/**
* 多个线程同时读⼀个资源类没有任何问题,所以为了满⾜并发量,读取共享资源应该可以同时进⾏。
* 但是,如果有⼀个线程想去写共享资料,就不应该再有其他线程可以对该资源进⾏读或写
* ⼩总结:
* 读-读 能共存
* 读-写 不能共存
* 写-写 不能共存
*/
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
//写锁
rwLock.writeLock().lock();
rwLock.writeLock().unlock();
//读锁
rwLock.readLock().lock();
rwLock.readLock().unlock();
5.7 死锁及解决方案
当两个(或更多)线程相互持有对方所需要的资源,又不主动释放,导 致所有人都无法继续前进,导致程序陷入无尽的阻塞,这就是死锁。
如果多个线程之间的依赖关系是环形,存在环路的锁的依赖关系,那么也可能会发 生死锁。
public class DeadLockTest {
private static final String ACTION_ONE = "拿起碗";
private static final String ACTION_TWO = "拿起筷子";
public static void main(String[] args) {
// 哲学家小明
new Thread(() -> {
synchronized(ACTION_ONE) {
try {
Thread.sleep(1000);
synchronized(ACTION_TWO) {
System.out.println("小明开始吃饭");
}
} catch (Throwable e) {
e.printStackTrace();
}
}
}).start();
// 哲学家小丽
new Thread(() -> {
synchronized(ACTION_TWO) {
try {
Thread.sleep(1000);
synchronized(ACTION_ONE) {
System.out.println("小丽开始吃饭");
}
} catch (Throwable e) {
e.printStackTrace();
}
}
}).start();
}
}
在上面的例子中,哲学家必须要同时拿起碗和筷子能够吃饭,吃完才会释放碗和筷子的锁。而小明和小丽去的这家餐厅,只剩下一只碗和一双筷子了。那么在他俩吃饭的场景下,就出现了死锁的问题。关于死锁问题,我们可以采取以下方式进行排查。
- jps指令: jps -l 可以查看运⾏的Java进程 。
- jstack指令: jstack pid 可以查看某个Java进程的堆栈信息,同时分析出死锁。
- 通过Arthas 分析工具的 thread -b排查是否有死锁的现象
避免死锁:
- 设置超时时间 Lock 的 tryLock(long timeout,TimeUnit unit)
- 多使用并发而不是自己的锁 ConcurrentHashMap、ConcurrentLinkedQueue、AtomicBoolean 等 java.util.concurrent.atomic 简单方便,效率比使用 Lock 更高
- 降低锁的使用粒度用不同的锁,而不是一个锁,例如ConcurrentHashMap分段锁
- 使用同步代码块不使用同步方法
5.8 偏向锁、轻量级锁、重量级锁
-
偏向锁
在锁对象的对象头中记录一下当前获取到该锁的线程ID,该线程下次如果又来获取该锁就可以直接获取到了。 -
轻量级锁
由偏向锁升级而来,当一个线程获取到锁后,此时这把锁是偏向锁,此时如果有第二个线程来竞争锁,偏向锁就会升级为轻量级锁,之所以叫轻量级锁,是为了和重量级锁区分开来,轻量级锁底层是通过自旋来实现的,并不会阻塞线程。 -
重量级锁
如果自旋次数过多仍然没有获取到锁,则会升级为重量级锁,重量级锁会导致线程阻塞。
5.9 Synchronized和Lock的区别
1.原始构成: sync 是JVM层⾯的,底层通过 monitorenter 和 monitorexit 来实现的。 Lock 是JDK API层⾯的。
2.使⽤⽅法: sync 不需要⼿动释放锁,⽽ Lock 需要⼿动释放。
3.是否可中断: sync 不可中断,除⾮抛出异常或者正常运⾏完成。 Lock 是可中断的,通过调⽤ interrupt() ⽅法。
4.是否为公平锁: sync 只能是⾮公平锁,⽽ Lock 既能是公平锁,⼜能是⾮公平锁。
5.绑定多个条件: sync 不能,只能随机唤醒。⽽ Lock 可以通过 Condition 来绑定多个条件,精确唤醒。
5.10 锁优化
减少锁持有时间
// 优化前
public synchronized void syncMethod1(){
othercode1();
mutextMethod();
othercode2();
}
// 优化后
public void syncMethod2(){
othercode1();
synchronized (this){
mutextMethod();
}
othercode2();
}
减少锁粒度
将大对象拆分成小对象,增加并行度,降低锁竞争。 ConcurrentHashMap 允许多个线程同时进入
锁分离
根据功能进行锁分离,ReadWriteLock 在读多写少时,可以提高性能。
锁消除
同样基于逃逸分析,当加锁的变量不会发生逃逸,是线程私有的完全没有必要加锁。 在JIT编译时期就可以将同步锁去掉,以减少加锁与解锁造成的资源开销。例如StringBuffer中的append方法。
锁粗化
大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;
5.11 死锁与活锁的区别 死锁与饥饿的区别
- 死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的 一种互相等待的现象,若无外力作用,它们都将无法推进下去。
- 活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试, 失败,尝试,失败。
- 活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,这就是所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
- 饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行 的状态。
6 并发编程常用辅助类
6.1 CountDownLatch 倒计时门闩
CountDownLatch 内部维护了⼀个计数器,只有当计数器==0时,某些线程才会停⽌阻塞,开始执⾏。
CountDownLatch(int count):只有一个构造函数,参数 count 为需要倒数的数值。
countdown():将 count 值减 1,直到为 0 时,等待的线程会被唤醒。
await():调用 await()方法的线程会被挂起,它会等待直到 count值为0 时才继续执行。
/**
* CountDownLatch主要有两个⽅法,当⼀个或多个线程调⽤await⽅法时,这些线程会阻塞。
* 其他线程调⽤countDown⽅法会将计数器减1(调⽤countDown⽅法的线程不会阻塞),
* 当计数器的值变为0时,因await⽅法阻塞的线程会被唤醒,继续执⾏
*/
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(6);
for (int i = 1; i <= 6; i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName() + "\t 上完⾃习,离开教室");
countDownLatch.countDown();
}, String.valueOf(i)).start();
}
countDownLatch.await();
System.out.println(Thread.currentThread().getName()+"/t班⻓最后关⻔⾛⼈");
}
}
6.2 Semaphore 信号量
CountDownLatch 的问题是不能复⽤。⽐如 count=3 ,那么加到3,就不能继续操作了。⽽ Semaphore 可以解决这个问题。用到了Semaphore.accquire() 和 Semaphore.release() ⽅法。
/**
* 在信号量上我们定义两种操作:
* acquire(获取)当⼀个线程调⽤acquire操作时,他要么通过成功获取信号量(信号量减
1),要么⼀直等待下去,直到有线程释放信号量,或超时。
* release(释放)实际上会将信号量加1,然后唤醒等待的线程。
*
* 信号量主要⽤于两个⽬的,⼀个是⽤于多个共享资源的互斥使⽤,另⼀个⽤于并发线程数的控制
*/
public class SemaphoreDemo {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(3);//模拟资源类,有3个空⻋位
for (int i = 1; i <= 6; i++) {
new Thread(()->{
try{
//占有资源
semaphore.acquire();
System.out.println(Thread.currentThread().getName()+"\t抢到⻋位");
try { TimeUnit.SECONDS.sleep(3); } catch
(InterruptedException e) {e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t停⻋3秒后离开⻋位");
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放资源
semaphore.release();
}
}, String.valueOf(i)).start();
}
}
}
6.3 Condition 接口(条件对象)
Condition 用来代替 Object.wait/notify,用法一样。一旦执行condition.await()方法,线程就会进入阻塞状态,当另一个线程执行 condition.signal()方法,此时 JVM 就会从被阻塞的线程里找到那些等待该 condition 的线程,它的线程状态就会变成 Runnable 可执行状态。condition可以实现精准通知,环形通知。
/**
* 备注:多线程之间按顺序调⽤,实现A->B->C
* 三个线程启动,要求如下:
* A打印5次,B打印10次,C打印15次
* 接着
* A打印5次,B打印10次,C打印15次
* 来10轮
* 1.⾼内聚低耦合前提下,线程操作资源类
* 2.判断/⼲活/通知
* 3.多线程交互中,防⽌虚假唤醒(判断只能⽤while,不能⽤if)
* 4.标志位
*/
public class ConditionDemo {
public static void main(String[] args) {
ShareData shareData = new ShareData();
new Thread(()->{
for (int i = 1; i <= 10; i++) {
shareData.printc1();
}
},"A").start();
new Thread(()->{
for (int i = 1; i <= 10; i++) {
shareData.printc2();
}
},"B").start();
new Thread(()->{
for (int i = 1; i <= 10; i++) {
shareData.printc3();
}
},"C").start();
}
}
class ShareData{
private int number = 1;//A:1,B:2,C:3
private Lock lock = new ReentrantLock();
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
private Condition c3 = lock.newCondition();
public void printc1(){
lock.lock();
try {
//1.判断
while (number != 1){
c1.await();
}
//2.⼲活
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName()+"\t"+i);
}
//3.通知
number = 2;
//通知第2个
c2.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void printc2(){
lock.lock();
try {
//1.判断
while (number != 2){
c2.await();
}
//2.⼲活
for (int i = 1; i <= 10; i++) {
System.out.println(Thread.currentThread().getName()+"\t"+i);
}
//3.通知
number = 3;
//如何通知第3个
c3.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void printc3(){
lock.lock();
try {
//1.判断
while (number != 3){
c3.await();
}
//2.⼲活
for (int i = 1; i <= 15; i++) {
System.out.println(Thread.currentThread().getName()+"\t"+i);
}
//3.通知
number = 1;
//如何通知第1个
c1.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
6.4 CyclicBarrier 循环栅栏
与 CountDownLatch 类似,都可以阻塞一组线程,CountDownLatch 是减,⽽ CyclicBarrier 是加。
public class CyclicBarrierDemo {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(7,()->{
System.out.println("======召唤神⻰");
});
for (int i = 1; i <= 7; i++) {
final int tempInt = i;
new Thread(()->{
System.out.println(Thread.currentThread().getName() + "\t 收集到第" + tempInt + "颗⻰珠");
try {
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
}, String.valueOf(i)).start();
}
}
}
7 阻塞队列
- 阻塞队列是在某些情况下会挂起线程(即阻塞),⼀旦条件满⾜,被挂起的线程⼜会⾃动被唤醒的队列
- 阻塞队列不⽤⼿动控制什么时候该被阻塞,什么时候该被唤醒
常见阻塞队列:
- ArrayBlockingQueue 由数组结构构成的有界阻塞队列
- LinkedBlockingQueue 由链表结构构成的有界(但默认值为Integer.MAX_VALUE)阻塞队列
- SynchronousQueue 不存储元素的阻塞队列,也即单个元素的队列
许多消息中间件底层就是⽤它们实现的。
需要注意的是 LinkedBlockingQueue 虽然是有界的,但有个巨坑,其默认⼤⼩是 Integer.MAX_VALUE ,⾼达21亿,⼀般情况下内存早爆了(在线程池的 ThreadPoolExecutor 有体现)。
7.1 BlockingQueue的常用方法
-
add(anObject)
把anObject加到BlockingQueue里,如果BlockingQueue可以容纳,则返回true,否则抛出异常。 -
offer(anObject)
表示如果可能的话,将anObject加到BlockingQueue里,如BlockingQueue可以容纳,则返回true,否则返回false。 -
put(anObject)
把anObject加到BlockingQueue里,如果BlockQueue没有空间,则调用此方法的线程被阻塞直到BlockingQueue里面有空间再继续。 -
poll(time)
取走BlockingQueue里排在首位的对象,若不能立即取出,则可以等time参数规定的时间,取不到时返回null。 -
take()
取走BlockingQueue里排在首位的对象,若BlockingQueue为空,阻塞进入等待状态直到Blocking有新的对象被加入为止。
其中:BlockingQueue不接受null元素。试图add、put 或offer一个null元素时,某些实现会抛出NullPointerException。null被用作指示poll操作失败的警戒值。
7.2 阻塞队列的应用——生产者消费者
为什么需要BlockingQueue?
好处是我们不需要关⼼什么时候需要阻塞线程,什么时候需要唤醒线程,因为这⼀切BlockingQueue都给你⼀⼿包办好了,使⽤阻塞队列 后就不需要⼿动加锁了。
public class ProdConsBlockQueueDemo {
public static void main(String[] args) {
MyResource myResource = new MyResource(new ArrayBlockingQueue<>
(5));
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t⽣产线程启动");
try {
myResource.myProd();
} catch (Exception e) {
e.printStackTrace();
}
}, "prod").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t⽣产线程启动");
try {
myResource.myProd();
} catch (Exception e) {
e.printStackTrace();
}
}, "prod-2").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t消费线程启动");
try {
myResource.myCons();
} catch (Exception e) {
e.printStackTrace();
}
}, "cons").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t消费线程启动");
try {
myResource.myCons();
} catch (Exception e) {
e.printStackTrace();
}
}, "cons-2").start();
try {
TimeUnit.SECONDS.sleep(5);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("5秒钟后,叫停");
myResource.stop();
}
}
class MyResource {
private volatile boolean FLAG = true; //默认开启,进⾏⽣产+消费
private AtomicInteger atomicInteger = new AtomicInteger();
private BlockingQueue<String> blockingQueue;
public MyResource(BlockingQueue<String> blockingQueue) {
this.blockingQueue = blockingQueue;
System.out.println(blockingQueue.getClass().getName());
}
public void myProd() throws Exception {
String data = null;
boolean retValue;
while (FLAG) {
data = atomicInteger.incrementAndGet() + "";//++i
retValue = blockingQueue.offer(data, 2L, TimeUnit.SECONDS);
if (retValue) {
System.out.println(Thread.currentThread().getName() + "\t"
+ "插⼊队列" + data + "成功");
} else {
System.out.println(Thread.currentThread().getName() + "\t"
+ "插⼊队列" + data + "失败");
}
TimeUnit.SECONDS.sleep(1);
}
System.out.println(Thread.currentThread().getName() + "\t⽼板叫停了,FLAG已更新为false,停⽌⽣产");
}
public void myCons() throws Exception {
String res;
while (FLAG) {
res = blockingQueue.poll(2L, TimeUnit.SECONDS);
if (null == res || "".equals(res)) {
// FLAG = false;
System.out.println(Thread.currentThread().getName() + "\t 超过2秒钟没有消费,退出消费");
return;
}
System.out.println(Thread.currentThread().getName() + "\t\t消费队列" + res + "成功");
}
}
public void stop() {
this.FLAG = false;
}
}
8 线程池
8.1 线程池介绍
使用线程池,可以复用线程及控制线程的总量。如果不使用线程池,每个任务都要新开一个线程来处理,反复创建线程开销大,过多的线程会占用太多内存。
8.2 使用线程池好处
线程池做的⼯作只要是控制运⾏的线程数量,处理过程中将任务放⼊队列,然后在线程创建后启动这些任务,如果线程数量超过了最⼤数量,超出数量的线程排队等候,等其他线程执⾏完毕,再从队列中取出任务来执⾏。
- 消除线程创建带来的延迟,加快响应速度。
- 合理利用 CPU 和内存
- 统一管理资源
8.3 线程池7大参数
/**
Creates a new ThreadPoolExecutor with the given initial parameters.
Params:
corePoolSize – the number of threads to keep in the pool, even if they are idle, unless allowCoreThreadTimeOut is set
maximumPoolSize – the maximum number of threads to allow in the pool
keepAliveTime – when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating.
unit – the time unit for the keepAliveTime argument
workQueue – the queue to use for holding tasks before they are executed. This queue will hold only the Runnable tasks submitted by the execute method.
threadFactory – the factory to use when the executor creates a new thread
handler – the handler to use when execution is blocked because the thread bounds and queue capacities are reached
Throws:
IllegalArgumentException – if one of the following holds: corePoolSize < 0 keepAliveTime < 0 maximumPoolSize <= 0 maximumPoolSize < corePoolSize
NullPointerException – if workQueue or threadFactory or handler is null
*/
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
- corePoolSize 线程池中的常驻核⼼线程数
- maximumPoolSize 线程池中能够容纳同时指向的最⼤线程数,此值必须⼤于等于1
- keepAliveTime 多余的空闲线程的存活时间,当前池中线程数量超过corePoolSize时,当空闲时间达到keepAliveTime时,多余线程会被销毁直到只剩下corePoolSize个线程为⽌
- unit keepAliveTime存活时间的单位
- workQueue 任务队列,存放已提交但尚未执⾏的任务
- threadFactory 表示⽣成线程池中⼯作线程的线程⼯⼚,⽤于创建线程,⼀般默认的即可
- handler 拒绝策略,表示当队列满了,并且⼯作线程⼤于等于线程池的最⼤线程数(maximumPoolSize)时,如何来拒绝请求执⾏的runnable的策略
8.4 线程池原理
- 如果线程数小于 corePoolSize,即使其它工作线程处于空闲状态,也会创建一个新线 程来运行新任务。
- 如果线程数大于等于 corePoolSize,但少于 maximumPoolSize,将任务放入队列。
- 如果队列已满,并且线程数小于 maxPoolSize,则创建一个新线程来运行任务。
- 如果队列已满,并且线程数大于或等于 maxPoolSize,则拒绝该任务。
8.5 xecutors 常用线程池
newFixedThreadPool线程池
创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
使⽤LinkedBlockingQueue实现,定⻓线程池。
newSingleThreadExecutor线程池
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
使⽤ LinkedBlockingQueue 实现,⼀池只有⼀个线程。
newCachedThreadPool线程池
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
使⽤ SynchronousQueue 实现,变⻓线程池。
newScheduledThreadPool线程池
创建一个定长线程池,支持定时及周期性任务执行。
newSingleThreadScheduleExecutor线程池
创建一个单线程执行程序,它可安排在给定延迟后运行命令或者定期地执行。与其他等效的 newScheduledThreadPool(1) 不同,可保证无需重新配置此方法所返回的执行程序即可使用其他的线程。
8.6 手动创建线程池
FixedThreadPool 和 SingleThreadExecutor 底层都是⽤ LinkedBlockingQueue 实现的,这个队列最⼤⻓度为 Integer.MAX_VALUE ,显然会导致OOM。
所以实际⽣产⼀般⾃⼰通过 ThreadPoolExecutor 的7个参数,⾃定义线程池
ExecutorService threadPool = new ThreadPoolExecutor(
2,
80*2,
1L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
自定义线程池参数选择:
- CPU 密集型(比如加密、计算 hash 等):最佳线程数为 CPU 核心数的 1-2 倍。
- 耗时 IO 型(读写数据库、文件、网络读写):最佳线程数应大于 CPU 核心数很多倍,以 JVM 线程监控显示繁忙为依据,保证线程空闲时可以衔接上,充分利用 CPU
线程数=CPU 核心数*(1+平均等待时间/平均工作时间)
阿里巴巴的《java开发手册》中规定:
-
AbortPolicy(默认策略)
丢弃任务并抛出RejectedExecutionException异常。 -
DiscardPolicy
丢弃任务,但是不抛出异常。 -
DiscardOldestPolicy
丢弃队列中最前面的任务,然后重新尝试执行任务。 -
CallerRunsPolicy
由调用线程处理该任务。
9 ThreadLocal
9.1 什么是ThreadLocal 类,怎么使用
ThreadLocal 为每个线程维护一个本地变量。
采用空间换时间,它用于线程间的数据隔离,为每一个使用该变量的线程提供一个 副本,每个线程都可以独立地改变自己的副本,而不会和其他线程的副本突。
ThreadLocal 类中维护一个 Map,用于存储每一个线程的变量副本,Map 中元素的键为线程对象,而值为对应线程的变量副本。
ThreadLocal 在 Spring 中发挥着巨大的作用,在管理 Request 作用域中的 Bean、事务管理、任务调度、AOP 等模块都出现了它的身影。
Spring 中绝大部分 Bean 都可以声明成 Singleton 作用域,采用 ThreadLocal 进行封装,因此有状态的 Bean 就能够以 singleton 的方式在多线程中正常工作了。
1.存储用户Session
private static final ThreadLocal threadSession = new ThreadLocal();
2.解决线程安全的问题
private static ThreadLocal<SimpleDateFormat> format1 = new ThreadLocal<SimpleDateFormat>()
9.2 底层原理
Thread、ThreadLocal、ThreadLocalMap 关系:
在每个 Thread 对象中都持有一个 ThreadLocalMap 成员变量。
一个 Thread 对象中可能拥有多个 ThreadLocal 对象。
ThreadLocalMap是一个键值对数组 Entry[] table,可以认为是一个 map。
键值对:
键:这个 ThreadLocal
值:实际需要的成员变量,如 user 或 simpleDateFormat 对象
ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。
因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map ,这个 Map 不是直接使用的 HashMap ,而是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。而我们使用的 get()、set() 方法其实都是调用了这个 ThreadLocalMap 类对应的 get()、set() 方法。
10 AQS(AbstractQueuedSynchronized)
AQS的全称为(AbstractQueuedSynchronizer)抽象的队列式的同步器,是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器,如:基于AQS实现的lock, CountDownLatch、CyclicBarrier、Semaphore。
AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH(虚拟的双向队列)队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。
AQS用法:
- 第一步:写一个类,想好协作逻辑,实现获取和释放方法
- 第二步:内部写一个 Sync 类继承 AbstractQueuedSynchronized
- 第 三 步 : 根 据 是 否 独 占 或 共 享 来 重 写 tryAcquire/tryRelease 或 tryAcquireShared(int acquires)和 tryReleaseShared(int release)等方法,在之前写的获取/释放方法中调用 AQS 的 acquire/release 或 Shared 方法。对于 tryAcquire,如果返回 true,则表示获取成功;否则返回 false。 对于 tryAcquireShared,如果返回一个负值,那么表示获取操作失败,返回零 值表示同步器通过独占方式被获取,返回正值表示同步器通过非独占方式被获 取。对于 tryRelease 与 tryReleaseShared 方法来说,如果返回 true,则表示已完全 释放,所有在获取同步器时被阻塞的线程都可以被恢复执行;否则返回 false。