文章目录
2 并发编程
2.1 线程
2.1.1 线程的几种状态
一个线程只能处于一种状态,并且这里的线程状态特指 Java 虚拟机的线程状态,不能反映线程在特定操作系统下的状态。
Java虚拟机中没有就绪态和运行态的区分,统一称为可运行态。
- 新建状态:线程创建但未启动
- 可运行态:正在Java虚拟机中运行,在操作系统层面可能是运行态也可能是就绪态
- 阻塞态:锁竞争导致线程阻塞
- 无限期等待态:等待与阻塞的区别在于等待是主动的、阻塞是被动的。无限期等待可能是wait()方法被等待唤醒
- 限期等待:一定时间后唤醒,如sleep()方法
- 死亡态:任务结束或因异常而结束线程。
有哪些方法可以保证线程安全?
- final修饰的一定是线程安全的,因为它不可变,如String类
- 互斥同步:synchronized或Lock锁保证线程安全
- 非阻塞同步:CAS保证线程安全、atomic类
- 无同步也可以保证线程安全
- 采用局部变量,线程私有当然线程安全
- 采用ThreadLocal保证线程安全
2.2 创建多线程的几种方式
- 直接继承Thread类,重写run方法。其实Thread类也是实现了Runable接口但是run方法为空。
- 实现Runnable接口,重写run方法,实现类作为参数传入Thread的构造方法。其实继承Thread类重写run方法和实现Runnable接口并作为构造函数的参数传入Thread创建对象没有区别。
- 实现Callable接口,重写call方法,将实现类包装成一个FutureTask对象作为参数传入Thread的构造方法。优点是可以带有返回值,缺点是相对复杂。
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。
由于创建线程和销毁线程有一定的代价,所以可以使用线程池来管理线程。线程池创建中有一个ThreadFactory接口作为参数,接口的实现中直接通过new Thread()
创建线程。
2.3 并发机制底层实现
2.3.1 synchronized关键字
synchronized关键字的作用
用于为Java对象、方法、代码块提供线程安全的操作,属于排它的悲观锁,也属于可重入锁。
synchronized关键字可作用于代码块、方法、静态方法。
- 修饰实例方法:作用于当前对象实例加锁。锁的是this
- 修饰静态方法:给当前类加锁。会作用于当前类的所有实例,因为静态成员不属于任何一个实例,是类成员。锁的是class
- 修饰代码块:收到传入一个锁对象,锁传入的对象
**注意:**当线程A调用synchronized修饰的非静态方法,线程b调用synchronized修饰的静态方法是被允许的。因为非静态方法是使用实例对象的锁,静态方法是使用类的锁。
synchronized的实现原理
在JVM中,对象在内存的的储存布局为:对象头(markword、类型指针)、实例数据、对齐填充。对象头主要结构是由Mark Word
和 Class Metadata Address
组成,其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息,Mark Word结构如下:
当锁标记为10时为重量级锁,有一个指针指向一个Monitor对象,monitor里面有一些数据结构如锁竞争队列ContentionList、竞争候选列表(EntryList)、等待集合WaitSet分别保存想要获得锁的线程、在锁竞争队列中有资格获得锁的线程、调用wait方法后阻塞的线程(三个集合中的线程都为阻塞状态)。monitor中还有个Owner标识位表示当前哪个线程获得锁,用于互斥。
- **修饰代码块时:**synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
- 修饰方法时:对方是否加锁是通过一个标记位来判断的。
synchronized如何保证可见性
- 线程解锁前,必须把共享变量的最新值刷新到主内存中
- 线程获得锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值
synchronized锁的执行流程
- 首先是偏向锁;如果一个线程获得了锁,那么锁就进入偏向模式,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查锁标记位为偏向锁以及当前线程ID等于对象头中的ThreadID即可,这样就省去了大量有关锁申请的操作。
- 轻量级锁;当第二个线程申请锁且没有锁竞争时,就转为轻量级锁,使用CAS方式修改共享变量。
- **自旋锁;**若轻量级锁失败,线程不会立即释放cpu资源,而是进行自旋持续的获取锁。(注:这种方式明显造成了不公平现象,最后申请的线程可能获取锁)
- **重量级锁;**轻量级锁失败的线程放入锁竞争队列(阻塞态);
虽然synchronized有锁升级的过程,但是这个过程基本不可逆,所以还是推荐使用Lock锁
2.3.2 synchronized与Lock的区别联系
- synchronized是java的关键字,JVM实现,通过monitor实现;Lock锁是JUC并发包中的实现,基于AQS模板重写tryAcquire、tryRelease实现;
- synchronized可以修饰方法,而Lock只能用于代码块。
- synchronized会自动释放锁,而Lock需手动释放。
- Lock可以是公平锁也可以是非公平锁,而synchronized只能是非公平锁。
- synchronized不可中断,除非抛出异常或者正常执行完毕;ReentrantLock可中断,tryLock可以设置超时时间,lockInterruptibly()放入代码块中,调用interrupt()方法可以中断。
- ReentrantLock可以绑定多个Condition条件用于实现分组唤醒需要唤醒的线程,实现精确唤醒。而synchronized要么随机唤醒一个要么全部唤醒。
- 两者都是可重入锁。
2.3.3 volatile关键字
轻量级的同步机制,保证可见性和禁止指令重排保证有序性。不保证原子性。
- volatile作用 :保证可见性和禁止指令重排。Java把处理器的多级缓存抽象为JMM,即线程私有的工作内存和线程公有的主内存,每个线程从主内存拷贝所需数据到自己的工作内存。volatile的作用就是当线程修改被volatile修饰的变量时,要立即写入到主内存,并通知其他线程该变量已经修改,当线程读取被volatile修饰的变量时,要立即到主内存中去读取,保证了可见性。禁止指令重排来保证顺序性。(单例模式的双重校验最好是加上volatile关键字,防止指令重排)
- 可见性的实现:每个线程从主内存拷贝所需数据到自己的工作内存。volatile的作用就是当线程修改被volatile修饰的变量时,要立即写入到主内存,并通知其他线程该变量已经修改,当线程读取被volatile修饰的变量时,要立即到主内存中去读取,保证了可见性。
- Volatile实现原理: ①JVM向处理器发送一条LOCK指令,表示将这个变量的缓存行的数据如果修改则写回到内存。②一个处理器的缓存行写回到内存会导致其他处理器的缓存无效(通过缓存一致性协议实现,处理器会嗅探总线上的传播数据来判断自己缓存的数据是否过期)。
总线风暴?
因为缓存一致性原理和CAS循环导致总线无效的交互太多,总线带宽达到峰值。
为什么volatile不保证原子性
javap查看字节码文件,如对一个volatile关键字修饰的变量n执行n++操作的指令是
getfield
iadd
putfield
指令被拆成了三个,那么多线程对一个数据进行修改时,会出现写覆盖的情况。当某个线程执行到getfield指令之后被挂起,那么该线程将获取不到其他线程修改后的最新数据。
如何保证volatile的原子性
如有n++等操作可以使用atomic类如atomicInteger。(atomic原理CAS)
指令重排的原理
指令重排:在保证数据依赖性的情况下,编译器优化可能对指令进行重排,指令重排在单线程情况下不会有任何问题。
在单线程情况下没有依赖性的数据在多线程情况下就可能有依赖性,就会出现问题。所以volatile关键字会禁止指令重排。通过内存屏障来实现禁止指令重排。
在哪里用到volatile
单例模式会用到。双重校验单例模式+volatile禁止指令重排
public class Main {
private volatile Main instance = null; //volatile关键字禁止指令重排,防止多线程情况下instance不为null但是还未初始化完成的情况出现。
private Main(){
System.out.println("执行构造函数");
}
public Main getInstance(){
//双重检验
if(instance == null){
synchronized (Main.class){
if(instance == null){
instance = new Main();
}
}
}
return instance;
}
}
当new一个对象时instance = new Main();
,大体上有三步:分配内存做默认初始化、执行初始化(构造方法)、将instance指向对象。如果不使用volatile关键字禁止指令重排,可能将第二步和第三步颠倒执行,即inst不为空时,还未初始化完成。
所以必须要使用volatile关键字禁止指令重排!
CPU层面如何禁止重排序?
通过内存屏障来禁止重排序。在两个指令中间加上内存屏障,即这两个不可以交换顺序
伪共享
一个缓存行为64字节,不仅仅包含一个数据,而是多个数据。如果一个缓存行中多个变量被volatile变量修饰,数据A在处理器1中修改后,处理器2读写数据B也要去内存中读取,即使数据B并没有失效,因为数据A和数据B在同一个缓存行!!
所以,如果两个volatile变量的定义挨在一起,很可能在一个缓存行里面,应尽量避免!
如果非要定义,使用cache line padding!在volatile变量前后声明7个long类型的变量填充。
2.3.4 atomic包和CAS原理及问题
AtomicInteger中的自增操作详解
AtomicInteger类的定义
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) {
throw new Error(ex); }
}
private volatile int value;
可见整个value使用volatile关键字修饰,保证可见性和禁止指令重排。
再看自增函数的实现
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
//this代表当前对象,valueoffset代表value这个值的内存偏移量,1代表要加1操作。
找到unsafe类的实现。do while循环自旋锁实现
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5; //声明修改前的值
do {
var5 = this.getIntVolatile(var1, var2); //本地方法,根据对象和内存偏移量获取值。
}
//自旋锁本地方法实现比较并交换。val1:当前对象 val2:位移偏移量 val5:修改前的值 val5+val4:修改后的值
//根据val1和val2获取当前值,与val5比较,若相等则将该值赋值为val5+val4
//compareAndSwapInt方法是利用cpu原语实现,不可中断保证原子性。
while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
-
处理器解决原子操作:
- 总线锁:处理器提供一个LOCK #信号,当一个处理器在总线上输出此信号时,其他处理器请求将被阻塞。(缺点:其他处理器也不能操作其他内存,开销大)
- 缓存锁:通过缓存锁定实现。
- 处理器提供一系列指令实现总线锁和缓存锁两个机制,CMPXCHG指令用于实现Java的CAS操作。
-
CAS的三大问题:
- ABA问题。Atomic包中有一个类可以解决这个问题
- 循环时间长时CPU开销大(并发太高的情况下不适用)
- 只能保证一个共享变量的原子操作;
-
ABA问题的解决:时间戳的原子引用
AtomicReference类
在atomic包中有基本的原子类实现,如果需要实现自己写的User类的原子操作就需要使用AtomicReference加泛型实现。
class User{ String username; int age; User(String name, int age){ this.username = name; this.age = age; } @Override public String toString() { return username+" "+age; } } public class Main { public static void main(String[] args) { User u1 = new User("aa", 18); User u2 = new User("bb", 20); AtomicReference<User> userAtomicReference = new AtomicReference<User>(u1); System.out.println(userAtomicReference.compareAndSet(u1, u2)); System.out.println(userAtomicReference.get()); } }
加上修改版本号解决ABA问题
在JUC包中有一个AtomicStampedReference类已经可以实现带版本号的原子引用。
new AtomicStampedReference<User>(u1,1); //u1为初始值,1为初始版本号。以后每次修改版本号加一
原理:类中是一个Pair类作为数据结构解决ABA问题,修改后版本后不一样。
private static class Pair<T> {
final T reference; //我们的数据
final int stamp; //版本号
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair; //最主要的数据,每次比较这个值
2.4 Java并发容器
2.4.1 List集合的线程安全
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for(int i=0;i<10;++i){
new Thread(()->{
list.add(UUID.randomUUID()