目录
AQS(AbstractQueuedSynchronizer)
并发编程
多线程
即在一个程序中可以同时运行多个不同的线程来执行不同的任务,允许单个程序创建多个并行执行的线程来完成各自的任务
为什么要使用多线程?
现在多核CPU的普及意味着多个线程可以同时运行,减少了线程上下文的切换,整体提高了程序的响应速度,提升了CPU的利用率
使用多线程带来的问题
并发编程是为了提高程序执行的效率,但是并发编程可能会导致一些问题,例如线程死锁、内存泄漏、线程不安全等
如何理解线程安全和不安全
-
线程安全指的是多线程环境下,对于同一份数据,不管有多少个线程访问,都能保证这个数据的正确性和一致性
-
线程不安全指的是多线程环境下,对于同一份数据,多个线程访问可能会导致数据混乱、错误或者丢失
什么是并发编程
并行:在同一个时间节点上,同时发生(是真正意义上的同时执行)
并发:在一段时间内,多个事情交替执行
并发编程:在例如买票,抢购,秒杀等场景下,有大量的请求访问同一个资源
会出现线程安全问题,所以需要通过编程来控制解决让多个线程依次访问资源,称为并发编程
并发编程的根本原因
多核CPU
JMM Java内存模型
Java内存模型是Java虚拟机规范的一种工作模式,开发者可以利用这些规范更方便的开发多线程程序,对于我们开发者来说,不需要了解其底层原理,只用一些关键字或者类来保证并发安全即可
将内存分为主内存和工作内存
变量数据存储在主内存中,线程在操作变量时,会将主内存中的数据复制一份到工作内存,在工作内存中操作完成后,再写回到主内存中,这样就可能导致一个线程在主内存中已经修改了变量,而另一个线程还在操作其副本,造成数据的不一致
多线程核心根本问题
基于Java内存模型的设计,多线程操作一些共享数据时,会出现以下3个问题:
不可见性:多个线程分别同时对共享数据操作,彼此之间不可见,操作完写回主内存,可能会出现问题
无序性:为了性能,对一些代码指令的执行顺序重排,以提高速度
int a = 从硬盘上实时读;
int b = 5;
int c = a+b;
非原子性:i++
int i = 0; 本来应该是2 结果是1 主内存
i++ i=0 i=1 工作内存
i++ i=0 i=1 工作内存
i++ 先从主内存加载数据到工作内存
操作数据
再从工作内存 写回到 主内存
缓存(工作内存)带来了不可见性
指令重排优化 带来了无序性
线程切换 带来了非原子性
解决办法
让不可见 变为 可见
让无序 变为 不乱序/不重排/有序
非原子 变为 原子(加锁) 由于线程切换执行导致的
-
可见性:当一个线程对共享变量进行修改,那么另外一个线程可以立刻看见新的值
-
有序性:由于指令重排序问题,代码的执行顺序未必就是写代码时候的顺序
-
原子性:一次或者多次操作,要么所有操作一起执行不受任何干扰中断,要么都不执行
volatile关键字
volatile 修饰的变量被一个线程修改后,可以在其他线程中立即可见,指示JVM,这个变量是共享且不稳定的,每次使用它都要去主内存中进行读取
volatile 修饰的变量,在执行的过程中不会被重排序执行,在对这个变量进行读写操作的时候,会插入特定的内存屏障的方式禁止指令重拍
volatile底层实现原理
在底层指令级别来进行控制
volatile 修饰的变量在操作前,添加内存屏障,不让它的指令干扰
volatile 修饰的变量添加内存屏障之外,还要通过缓存一致性协议(MESI)将数据写回到主内存,其他工作内存嗅探后,把自己工作内存数据过期,重新从主内存读取最新的数据
volatile 不能解决原子性问题
比如我们要实现一个i++
的操作,这个操作是一个复合操作,主要有三步:
-
读取i的值
-
对i加1
-
将i的值写回内存
volatile是无法保证这三个操作的原子性的,有可能导致以下情况:
-
线程1对i的值进行读取之后还未进行修改,此时线程2也来读取i的值并对其进行修改(+1),再将i的值写回内存
-
线程2操作完后,线程1对i的值加1,再将i的值写回内存
这样本来最终结果应该是2,结果是1
原子性
只有通过加锁的方式,让线程互斥执行来保证一次只有一个线程对共享资源访问
synchronized:关键字 修饰代码块,方法 自动获取锁,自动释放锁
ReentrantLock:类 只能对某段代码修饰 需要手动加锁,手动释放锁
在Java中还提供了一些原子类,在低并发情况下使用,是一种无锁实现,例如AtomicInteger
采用CAS(Compare-And-Swap) 比较并交换)是一种无锁实现,在低并发情况下使用
Java中的锁
有很多锁并不全指锁,一些锁的名词指的是锁的状态、特性、设计
悲观锁
悲观的认为不加锁的并发操作一定会出现问题,共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源给其他线程,例如synchronized
和ReentrantLock
等独占锁
特点:适用于写操作多的情况
缺点:高并发下,会导致大量线程阻塞,增加系统性能开销,可能会出现死锁问题
乐观锁
认为并发情况下,共享资源被操作是不会出现问题的,线程无需加锁也无需等待,可以一直执行下去,每次操作前判断CAS(自旋)是否成立,是不加锁的实现
例如Java 中java.util.concurrent.atomic
包下面的原子变量类(AtomicInteger等)就是使用了乐观锁的CAS方式实现的
特点:适用于读操作多的情况
优点:不存在线程阻塞和线程死锁,性能上往往更好一些
缺点:如果冲突频繁发生,会频繁失败和重试,导致CPU占用率飙升
如何实现乐观锁?
一般使用版本号机制或者CAS算法
版本号机制
在数据表上加一个 version
字段,表示数据被更改的次数。当数据被更改时, version
的值会加1。当线程A要更新数据时,在读取数据时也会读取 version
值,在提交更新时,若读取的 version
值和数据库的一样才更新,否则重试更新操作,直到更新成功
CAS算法
CAS全称Compare And Swap(比较与交换),实现思想很简单,用一个预期值和要更新的变量值进行比较,两值相等才会进行更新
CAS是原子操作,也就是说一旦开始,不能打断,直到操作结束
CAS 涉及到三个操作数:
-
V:要更新的值(val)
-
E:预期值
-
N:要写入的新值
当且仅当V的值等于E时,CAS用N值更新V值,如果不等,则当前线程放弃更新
举个例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。
-
i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
-
i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。
当多个线程同时使用CAS更新值,只有一个会成功,其他的都是失败,但是只是被告知失败,会允许再次尝试,当然也允许失败的线程放弃尝试
乐观锁存在的问题
ABA问题
如果一个变量V初始读到的值A,再次赋值的时候发现还是A,并不能说明其没有被其他线程修改过,有可能是修改过后再次修改为A,那么CAS会误认为没有修改,这就是ABA问题
解决思路是在变量前面追加上版本号或者时间戳,例如AtomicStampedReference
其中的 compareAndSet()
会先检查预期值和当前值是否相等,并且当前标志是否等于预期标志,如果全部相等就会更新新值
可重入锁
当一个线程获取到外部方法的同步锁对象后 依然可以获取到其内部的同步锁对象 可以在一定程度上避免线程死锁
synchronized和ReentrantLock都是可重入锁
public class Demo {
synchronized void setA() {
System.out.println("方法A");
setB();
}
synchronized void setB() {
System.out.println("方法B");
}
public static void main(String[] args) {
Demo demo = new Demo();
demo.setA();// 方法A 方法B
}
}
读写锁
ReentrantReadWriteLock
读写都可以加锁
读读不互斥 读写互斥 写写互斥
如果加读锁是为了防止在写数据时读数据 从而造成读脏数据
分段锁
不是锁 而是一种思想 将数据分段 在每段上单独加锁 提高效率
自旋锁
不是锁 以自旋的方式重新获取锁 由此可见 自旋锁是很消耗CPU的 因为要不断的重复尝试
共享锁/独占锁
共享锁:可被多个线程所共有 读写锁中的读锁就是共享锁
读读不互斥 共享
互斥锁:synchronized和ReentrantLock属于独占锁
只能被一个线所持有
公平锁/非公平锁
公平锁: 按照先来后到的顺序或得到锁 比如ReentrantLock就可以实现公平锁,上下文切换繁琐
非公平锁:没有顺序 谁先抢到谁获得执行权 ReentrantLock可以是非公平锁 synchronized一定是非公平锁,性能较好,但是可能导致某些线程永远获取不到锁
synchronized锁
synchronized
是Java中的一个关键字,主要解决多个线程之间访问线程的同步性,可以保证被它修饰的方法或者代码块在任意时刻只有一个线程执行
如何使用synchronized?
-
修饰实例方法(锁当前对象实例)
-
修饰静态方法(锁当前类)
-
修饰代码块(锁指定对象或类)
synchronized的底层原理
synchronized同步语句块的情况
public class Demo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过jdk自带的一些命令可以了解到:synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
字节码包含一个monitorenter
和两个monitorexit
,是为了保证同步代码块在异常和正常执行完毕都可以被正确释放
在执行monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。如果获取对象锁失败,该线程就要阻塞等待
在执行monitorexit
时,会判断是否是对象锁的拥有者,是的话才会将锁计数器设为0,不是的话则释放失败
synchronized修饰方法的情况
public class Demo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
修饰方法是会有一个标志 ACC_SYNCHRONIZED
,JVM通过该标志来辨别一个方法是否是同步方法
总结
二者本质都是对对象监视器 monitor 的获取。
synchronized和volatile有什么区别?
-
volatile关键字是线程同步的轻量级实现,性能比synchronized好,但是volatile只能修饰变量,synchronized可以修饰方法和代码块
-
volatile可以保证数据的可见性,但不能保证原子性,synchronized可以两者都保证
-
volatile保证多个线程下数据的可见性,synchronized保证多个线程下访问资源的同步性
synchronized锁升级的原理与实现
在synchronized锁的底层实现中 又有锁的不同状态 用来区别对待
这个锁的状态在同步锁对象的对象头中 有一个区域叫Mark Word中存储
-
无锁状态:对于共享资源,不存在多线程竞争访问
-
偏向锁状态:一直被一个线程访问 记录线程的id,下次访问直接比对id快速获取锁
-
轻量级锁状态:当多个线程同时申请共享资源,产生了竞争关系,JVM会使用轻量级锁 其他线程会采用自旋的方式获取锁 获得不到锁的线程不会阻塞 提高效率
-
重量级锁状态:当锁是轻量级锁时 其他线程采取自旋方式获得锁 自旋到一定次数时 还没获得锁 就会阻塞 该锁变为重量级锁 等待操作系统调度
注意:无锁到偏向锁并不是升级,而是开启偏向锁,偏向锁未开启,会直接从无锁到轻量级锁状态
ReentrantLock
实现了Lock接口,是一个可重入独占锁,和synchronized类似,但是增加了轮询、超时、中断、公平锁和非公平锁功能,更加灵活。
ReentrantLock默认非公平,也可以通过构造器实现公平锁
ReentrantLock lock1 = new ReentrantLock(true);//true-公平实现,false-非公平实现
非公平
NonfairSync
final void lock() {
if (compareAndSetState(0, 1))// 线程来到后 直接尝试获取锁 是非公平的
setExclusiveOwnerThread(Thread.currentThread());
else// 获取不到
acquire(1);
}
公平实现
FairSync
final void lock() {
acquire(1);
}
底层是由AQS来实现的
AQS(AbstractQueuedSynchronizer)
抽象队列是juc其他锁实现的基础
AQS的原理
如果被请求的资源空闲,则将当前线程设为工作线程,并且将请求资源设为锁状态,如果请求资源已被占用,那么就需要线程阻塞等待被唤醒资源分配的机制,这个机制是用CLH队列锁实现的
CLH队列锁
是一个虚拟的双向队列,不存在实例,只是有结点之间的相互关系。AQS会将线程封装成CLH锁队列中的节点Node,在此队列中,一个节点代表一个线程,其中保存着线程的引用(thread)、当前节点的状态(waitStatus)、前驱(prev)后继(next)
思路:
在类中维护一个state变量,然后还维护一个队列,以及获取锁,释放锁的方法
内部有一个state变量表示锁是否使用 初始化为0 当有线程在使用时,就将state加1 其他来的线程进入到抽象队列中 当线程执行完毕释放资源时, state减1 变为0 那队列中的线程遵循FIFO的原则再去获取
面试题:synchronized与Lock的区别
-
synchronized是关键字,是JVM底层实现;Lock是一个接口,依赖于API
-
synchronized会自动释放锁,而Lock必须手动释放锁
-
synchronized是不可中断的,Lock可以中断也可以不中断
-
可中断锁:不需要一直等待锁被获取,可以中断获取过程去做其他事情
-
不可中断锁:一旦线程申请锁,必须等到获取到锁后,才可以去进行其他的逻辑处理
-
-
通过Lock可以知道线程有没有拿到锁,因为Lock有返回值,而synchronized不能
-
synchronized能锁住方法和代码块,Lock只能锁住代码块
-
synchronized是非公平锁,ReentrantLock可以实现是否公平
JUC常用类
ConcurrentHashMap
线程安全的HashMap 效率比Hashtable高 因为ConcurrentHashMap是分段加锁 并不是整个方法加锁
Hashtable和ConcurrentHashMap的键和值都不能为null
存储结构
Node数组+链表/红黑树,初始化是通过自旋CAS操作完成的
put流程
-
根据key计算出哈希值
-
判断是否需要初始化
-
为当前key定位出Node,如果桶为空利用CAS写入,失败则自旋保证成功
-
如果当前位置
hashcode == MOVED == -1
,则需要扩容 -
如果都不满足,则利用synchronized锁写入数据
-
如果链表长度大于8并且数组长度大于64就转为红黑树
get流程
-
根据hash值计算位置
-
查找到指定位置,如果头结点就是找的,直接返回value
-
如果头结点hash<0,则说明正在扩容或者是红黑树,用find方法查找
-
如果是链表,直接遍历查找
为什么不能put(null)?
为了消除歧义
如果put null的话,当我们使用get方法时,获取到的null无法分清是没有这个key还是这个key的值为null 这在多线程里是模糊不清的
CopyOnWriteArrayList
核心是写时复制(Copy-On-Write)
ArrayList是线程不安全的 Vector是线程安全的 但是Vector在读的时候也加了锁 所以读的时候效率会很慢
而CopyOnWriteArrayList在写的时候加了ReentrantLock锁 但在读时候并没有加锁 所以其效率比较高
在进行add,set等修改操作时,先将数据进行备份,对备份的数组进行修改,之后将修改后的数据赋值给原数组.
CopyOnWriteArraySet
CopyOnWriteArraySet 的实现基于 CopyOnWriteArrayList,不能存储重复数据。
CountDownLatch
辅助类 允许一个线程等待其他线程执行完毕再执行 底层实现还是通过AQS来完成的
原理
当线程使用 countDown()
方法时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少 state
,直至 state
为 0 。当调用 await()
方法的时候,如果 state
不为 0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞,也就是说 await()
方法之后的语句不会被执行。直到count
个线程调用了countDown()
使 state 值被减为 0,或者调用await()
的线程被中断,该线程才会从阻塞中被唤醒,await()
方法之后的语句得到执行。
package com.zl.javapro.thread.sync;
import java.util.concurrent.CountDownLatch;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(6);//设置线程总量
for (int i = 0; i < 6 ; i++) {
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("aaaaaaaaaaaaa");
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
System.out.println("main线程执行");//最后执行的内容
}
}
线程池
数据库连接池、字符串常量池等,用完不会立刻销毁,而是等待下一个任务的到来
池的概念
频繁的创建数据连接对象,销毁,时间上开销较大
在JDK5之后,提供线程池的实现
使用ThreadPoolExecutor类实现线程池的创建管理
池的好处:减少频繁创建销毁时间,统一管理线程,提高效率
ThreadPoolExecutor
7个参数:
corePoolSize:核心线程池的大小
maximumPoolSize:线程池最大数量
keepAliveTime:非核心线程池中的线程,没有任务执行时保持多久时间会终止
unit:为keepAliveTime设定单位
workQueue:一个阻塞队列,用来储存等待执行的任务
ArrayBlockingQueue 有界的阻塞队列,必须给定最大容量
threadFactory:线程池工厂,主要用来创建线程
handler:拒绝策略 核心线程池,阻塞队列,非核心线程池已满,继续有任务,如何执行.
AbortPolicy(); 抛出异常,拒绝执行.
DiscardOldestPolicy(); 丢弃等待时间最长的任务.
DiscardPolicy(); 直接丢弃,不执行.
CallerRunsPolicy(); 交由当前提交任务的线程执行.
线程执行流程图:
execute 与 submit 的区别
都是提交任务的,execute方法可以没有返回值
submit可以有返回值
关闭线程池
shutdownNow :关闭线程池,强制关闭
shutdown:关闭线程池,拒绝新的任务,不会强制关闭,等待任务执行完再关闭
为什么阿里巴巴不允许使用 Executors创建线程池
-
FixedThreadPool
和SingleThreadExecutor
:使用的是无界的LinkedBlockingQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。 -
CachedThreadPool
:使用的是同步队列SynchronousQueue
, 允许创建的线程数量为Integer.MAX_VALUE
,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。 -
ScheduledThreadPool
和SingleThreadScheduledExecutor
: 使用的无界的延迟阻塞队列DelayedWorkQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。
ThreadLocal
本地线程变量,可以为每个线程创建一个变量副本,使得多个线程相互隔离不影响
如果创建一个ThreadLocal变量,那么访问这个变量的时候每个线程都会创建一个本地副本,进行操作也是对副本的操作,从而避免线程的安全问题
ThreadLocal底层实现
看了ThreadLocal
底层源码,了解到最终的变量是放到当前线程的ThreadLocalMap
中,并不是存在ThreadLocal
上,ThreadLocal
可以理解为是ThreadLocalMap
的封装
为每个当前线程创建一个ThreadLocalMap,唯一的ThreadLocal对象作为key,Object为value
ThreadLocal内存泄漏问题
因为ThreadLocal与弱引用有关,key失效后,value还被强引用着,造成内存泄漏
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
调用 set()
、get()
、remove()
方法的时候,会清理掉 key 为 null 的记录。正确用法,用完之后,及时调用remove()
方法清除