Java并发编程知识点总结
文章目录
- 2 并发编程
- 2.1 线程
- 2.1.1 线程的几种状态
- 有哪些方法可以保证线程安全?
- 2.2 创建多线程的几种方式
- 2.3 并发机制底层实现
- 2.3.1 synchronized关键字
- 2.3.2 synchronized与Lock的区别联系
- 2.3.3 volatile关键字
- 2.3.4 atomic包和CAS原理及问题
- 2.4 Java并发容器
- 2.4.1 List集合的线程安全
- 2.4.2 Set集合类的线程安全
- 2.4.3 Map的线程安全
- 2.4.4 CountDownLatch(倒计数)
- 2.4.5 CyclicBarrier(计数器)
- 2.4.6 Semaphore信号量
- 2.4.7 阻塞队列
- 2.4.2 AQS详解
- 2.4.3 ReenTrantLock详解
- 2.5 锁
- 2.5.1 公平锁和非公平锁
- 2.5.2 可重入锁
- 2.5.3 自旋锁
- 2.5.4 读写锁
- 2.6 生产者消费者的几种实现
- 2.6.1 lock锁实现
- 2.6.2 阻塞队列实现
- 2.7 线程池详解
- 2.7.1 ThreadPoolExecutor创建线程池
- 2.7.2 使用Executors创建线程池
- 2.7.3 阻塞队列串讲
- 2.8 多线程中死锁
- 2.9 ThreadLocal类
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;}@Overridepublic 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集合的线程安全
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
