并发编程
文章目录
线程知识点回顾
程序:计算机上具有某种功能的,由特定语言编写的指令集合。是静态代码块。
进程:正在运行的程序。是操作系统进行资源分配的最小单位。
线程:进程的进一步细化。是操作系统调度的最小单位。
一个进程可以有多个线程,一个线程只属于一个进程。
创建线程的三种方式:
- 继承Thread类,重写run();
- 实现Runnable接口,重写run();
- 实现Callable接口,重写call();
线程的物种状态:
创建、就绪、运行、阻塞、死亡。
线程中的方法:
start():开启线程。
sleep():使得线程休眠。(时间结束后自动苏醒,加锁后休眠期间其他线程不可以执行)。
wait():使得线程休眠。(需要notify()或者notifyAll()方法唤醒,加锁后挂起其他线程可执行)。
notify():唤醒指定线程。
notifyAll():唤醒全部线程。
多线程:一个程序可以创建多个线程并行执行。
多线程
多线程:一个程序可以创建多个线程并行执行。
优点:提高CUP的利用效率,程序响应速度提高了,程序的性能得到提高。
缺点:安全性问题(对同一共享资源的操作)、线程过多占内存、CUP需要不断切换线程导致性能问题。
并行和并发
多核CUP情况下:
并行:在一个时间节点上,同时执行多个线程;
并发:在一段时间内轮流执行线程。
什么是并发编程?
例如在买票和秒杀这种大量线程同时访问一个共享资源时,通过编程实现线程依次对共享资源进行访问。
并发编程的根本原因?
Java内存模型(JMM)
Java内存模型(Java Memory Model,JMM):是Java虚拟机规范的一种工作模式。
将内存分为主内存(读取速度慢)和工作内存(读取速度快)。
变量数据存储在主内存中,线程在操作变量时将主内存的变量复制到工作内存中,当操作完成后,又写回到主内存中。
优点:提高了效率。
缺点:当有多个线程访问操作同一个变量时,最终返回主内存的值是不准确的。
并发编程的核心问题-可见性、有序性、原子性
基于Java内存模式下有三大问题:
可见性
多核CPU每个CPU内核都有自己的缓存,而缓存仅仅对它所在的处理器内部课件,CPU缓存与内存保持一致是不容易的。为了防止处理器多次停顿下来向内存写入数据产生延迟,处理器使用写缓存区,临时保存向内存写入的数据。也就是数据不会立即写入到内存中。
不可见性:各个线程在执行时对其他线程来说是不可见的,也就是说其他并不知道已经有线程操作了共享资源。
有序性
无序性:为了性能优化有时会将程序的编译顺序打乱。例如:
读取文件执行比较慢,就会执行其下一句指令。
原子性
原子性:原子性指定是不能分割的指令。但是例如count++操作,是需要三步的:①、从内存中读取count;②、执行count+1操作;③、将count+1的结果写回内存。错误的情况:
有两个线程都可以执行count++;此时线程A读取到了count=0,然后cpu切换,线程B读取到了count=0,并执行了count+1操作,将1写回到内存,cup又切换到线程A,执行count+1,将1写回到内存。最终的结果与我们预期的并不相同。
并发问题总结
缓存导致不可见问题,编译优化导致无序问题,线程切换导致非原子问题。
volatile关键字
可以解决不可见问题和无序性问题,但是不能解决非原子性问题。
volatile的底层原理
使用内存屏障(Memory Barrier)。
内存屏障:是一条指令,该指令可以对编译器(软件)和处理器(硬件)的指令重排做出限制。变量编译为汇编指令会多出#Lock 前缀。
可见性实现:主要通过Lock前缀指令+MESI缓存一致性协议来实现的。对volatile修饰的变量执行写操作时,JVM会发送一个Lock前缀指令给CPU,CPU执行完写操作后,会立即将新的值刷新到内存中,同事因为MESI缓存一致性协议,其他线程都会对总线嗅探,看自己本地缓存中的数据是否被修改了,如果发现修改了就会从内存中读取新的数据值。
有序性实现:主要通过对 volatile 修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性的。
如何保证原子性
同一时刻只有一个线程执行,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么就能保证原子性。
锁可以实现互斥,也就是可以保证原子性。
在java中还提供了一些原子类(如AtomicInteger),是一种无锁实现,采用CAS机制,适用于地并发。
原子类
原子类的原子性是通过 volatile + CAS 实现原子操作的。
AtomicInteger 类中的 value 是有 volatile 关键字修饰的,这就保证了 value
的内存可见性,这为后续的 CAS 实现提供了基础。
低并发情况下:使用 AtomicInteger。
CAS
CAS(Compare-And-Swap) :比较并交换,该算法是硬件对于并发操作的支持。
是乐观锁的一种实现方式,采用自旋思想,一种轻量级的锁机制。
CAS原理:
- 第一次获取内存值到工作内存中来,作为预估值;
- 进行操作;
- 将数据存入到内存时,比较预估值与内存中的值是否一致,不一致则重新读入数据进行操作,一致则保存到内存中。
特点:不加锁,其他线程都可以对共享数据操作;适用于低并发;由于不加锁,执行效率高。
缺点:并发量大时,反复自旋,效率低,同时存在ABA问题。
如何解决ABA问题
ABA问题:当一个线程将共享值由A改为B,再由B改为A,另一个线程在存储的时候,与内存中的值进行比较,发现任然是A,当前CAS无法判断共享值已经改过了。
解决ABA问题:可以通过添加版本号来解决。内存中的共享值(A,1),一个线程修改为B时,内存值为(B,2),在修改为A时,内存值为(A,3)。另一个线程在比较判断时(A,1)与(A,3)进行比较。
锁的分类
synchronized:关键字,修饰方法和代码块,自动加锁和释放锁,修饰方法时锁对象为this。
ReentranLock:类,需要手动添加锁和释放锁。
java中有很多锁的名词,但是并不都是锁,有些是设计、特征、状态等。
乐观锁
乐观锁:乐观的认为所有对共享资源的并发操作不加锁是可以的,不会出问题。会采用尝试更新,和不断重新更新数据。
悲观锁
悲观锁:悲观的认为所有对共享资源的并发操作都是不安全的,会出问题。采用加锁的方式。
乐观锁和悲观锁都不是指锁,而是指看待并发同步的角度。
从上面可以看出,悲观锁适用于写操作较多的情况,乐观锁适用于读操作较多的情况。
可重入锁
可重入锁:又叫递归锁,一个线程中外层方法在获得锁后,内部方法自动获得锁。
ReentranLock和synchronized都是可重入锁。
public synchronized static void A(){
System.out.println("方法A");
B();
}
public synchronized static void B(){
System.out.println("方法B");
}
如果不是可重入的锁的话,B();就不会执行。
读写锁
读写锁的特点:读读不互斥,读写互斥,写写互斥。也就是说只要涉及写都要互斥。
分段锁
分段锁:并非是一种实际上的锁,而是一种思想,用于将数据分段,并在每个分段上都会加上锁,把锁进一步细化,提高并发效率。
自旋锁
自旋锁:反复重试,抢锁失败后,经过几次重试后,抢到锁了就执行,没有抢到就阻塞。目的是为了减少线程阻塞。反复重试,会消耗CPU性能,但是在锁时间普遍较短的情况下可以提高性能。
共享锁
共享锁:多个线程可以同时持有锁。如上面提到的读锁ReadWriteLock。
独占锁
独占锁:一次只允许一个线程持有锁。
ReentranLock和synchronized都是独占锁。
公平锁
公平锁:按照请求锁的先后顺序分配。
FairSync
final void lock() {
acquire(1);
}
非公平锁
非公平锁:抢占式分配。
NonfairSync
final void lock() {
if (compareAndSetState(0, 1))//线程来到后,直接尝试获取锁,是非公平
setExclusiveOwnerThread(Thread.currentThread());
else//获取不到
acquire(1);
}
synchronized是非公平锁,ReentranLock默认是非公平锁,但是底层可以通过AQS实现线程调度,称为公平锁。
偏向锁
偏向锁表示一段同步代码一直被一个线程访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁
轻量级锁指的是当锁是偏向锁的时候,又有另外一些线程访问,此时升级为轻量级锁。没有获得锁的线程,不会阻塞,继续不断尝试获得锁。
重量级锁
重量级锁指的是当锁的状态为轻量级时,线程的自旋达到一定次数时,会进入阻塞状态,此时升级为重量级锁。
对象结构
在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充。
对象的对象头中,有一个区域叫Mark Word中存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。
对象头的一些信息:
synchronized锁
在synchronized锁的底层实现中,提供锁的状态,用来区别对待。
这个锁的状态在同步锁对象的对象头中,有一个区域叫Mark Word中存储。
锁状态
锁状态:无锁、偏向锁、轻量级苏、重量级锁。
锁状态会通过对象头中的字段来表明的。
四种状态会随着竞争的情况逐级递增。
AQS
AQS全程为(AbstractQueuedSynchronizer)抽象同步队列。是JUI的实现基础。ReentrantLock就是基于AQS来实现的。
AQS的实现原理
在类中维护一个state变量(volatile修饰保证可见性),然后还维护一个队列,以及获取锁,释放锁的方法。
当线程创建后,先判断state值,如果为0 ,表示此时没有线程使用锁,把state=1(执行state+1操作),执行完成后将state=0(执行state-1操作)。
执行期间如果有其他的线程访问,state=1,表示已经有线程占据锁对象,将其他线程放入IFIO队列中。
等待state减一操作变为0时,在IFIO队列找那个唤醒下一个等待线程。
AQS的锁模式分为:独占和共享
ReentrantLock锁实现
ReentrantLock基于AQS在并发编程中它可以实现公平锁和非公平锁来对共享资源进行同步,同时和 synchronized 一样,ReentrantLock 支持可重入,除此之外,ReentrantLock 在调度上更灵活,支持更多丰富的功能。
ReentrantLock总共有三个内部类:Sync、NofairSync和FairSync
JUC常用类
Java 5.0 之后再java.util.concurrent包中提供了多种并发容器类,用来改进同步容器的性能。
ConcurrentHashMap
HashMap:线程不安全。
Hashtable:线程安全的。使用synchronized将整个方法都封锁住,效率低。
ConcurrentHashMap:线程安全的。内部使用“所分段”机制(jdk8,放弃了分段锁,使用CAS+synchronized)代替Hashtable的独占锁,从而提高性能。
放弃分段锁的原因:
- 加入多个锁浪费内存空间。
- 生产环境中,Map在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的等待时间更长。
jdk8放弃了分段锁而使用Node锁,降低了锁的力度,提高性能,并使用CAS操作来确保Node的一些操作的原子性,取代了锁。
put时首先通过hash找到对应链表后,查看是否是第一个Node,如果是,直接用CAS原则插入,无需加锁。然后,如果不是链表的第一个Node,则直接用链表的第一个Node加锁,这里的锁是synchronized。
ConcurrentHashMap 不支持存储null键和null值。为了消除歧义。
不能put null:无法分辨是key对应的值为null,还是key键为null。
ConcurrentHashMap 和Hashtable都是支持并发的,这样存在的问题是,当使用get(k)获取value时,如果获取到的值为null,到底是put(K,V)时对用的value就是null,还是不存在这个key。
CopyOnWriteArrayList
ArrayList是线程不安全的,Vector是线程安全的。
但是在很多应用场景中,读操作可能远远大于写操作,由于读操作不涉及修改数据是可以不用加锁的,如果每次都加锁的话,会造成资源浪费。
jdk中提供了CopyOnWriteArrayList类,将读取的性能发挥到极致,取是完全不用加锁的,并且更厉害的是:写入不会阻塞读取操作,只有在写写时进行同步,提高了读操作的性能。
实现原理:
CopyOnWriteArrayList类中的add,set等方法都是通过创建底层数组的副本来实现的。在list被修改时,不是直接修改原有数组对象,而是对原有数组进行一次复制,将修改的内容写入到副本中。写完之后,在将修改完的数据替换原来的数据。
CopyOnWriteArraySet
CopyOnWriteArraySet 的实现基于 CopyOnWriteArrayList,不能存储重复数 据。
在添加数据是调用CopyOnWriteArrayList中的addIfAbsent(E e) 方法实现不存储重复数据。
辅助类CountDownLatch
CountDownLatch 允许一个线程等待其他线程各自执行完成后再执行。底层是通过AQS来完成的。创建CountDownLatch 对象时需要制定一个初始的线程数量。每当一个线程执行完成后,state就-1,直到state的值为0时,表示所有线程都执行完成,此时等待的线程就可以执行了。
对象引用
我们希望当内存空间不足时,能将某些对象保留在内存中;如果内存空间在进行垃圾收集之后还是很紧张,则可以一并回收这些对象。
jdk1.2之后,java将对象引用的概念进行了扩充,分为:强引用、软引用、弱引用、虚引用。4中引用强度一次逐渐减弱。
强引用
强引用:指的是程序代码中普遍的引用赋值,如:Object obj = new Object ();无论何种情况下只要强引用关系还在,垃圾收集器就永远不会被回收掉。
软引用(SoftReference)
软引用:内存不足就收回。用来描述一些还有用,但是非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之内二次进行回收,如果这次回收之后内存空间还不足,才会抛出内存溢出异常。注意:第一次回收,回收的是不可达对象。
java虚拟机会尽量让软引用存活的时间长一些。
弱引用(WeakReference)
弱引用:发现就回收。用来描述非必需的对象,只被弱引用关联的对象只能存活到下一次垃圾收集为止。系统GC,只要发现弱引用,不管空间是否充足,都会回收。
虚引用(PhantomReference)
虚引用:对象回收跟踪。也称为“幽灵引用”和“幻影引用”,所用引用中最弱的一个,如果一个对象只持有虚引用,那就和没有引用是一样的。随时会被回收。
线程池
以前我们需要一个线程就会创建一个线程,但是问题是:如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁的创建和销毁线程会大幅降低系统效率,因为创建和销毁线程都是需要时间的。
java中可以通过线程池来解决此问题。线程池里的每个线程代码结束后,并不会死亡,而是在回到线程池找中,等待下一个对象来使用。jdk5之后,java内置支持线程池实现ThreadPoolExecutor。
jdk5同时提供了Executors来创建不同类型的线程池,通常情况下不建议程序员使用。
线程池的优点:
重复利用线程,降低线程创建和销毁带来的资源消耗。
统一管理线程,线程的创建和销毁都是由线程池管理的。
提高响应速度,线程创建已经完成,任务来到直接可以处理,省去了创建线程时间。
ThreadPoolExecutor类
Java.uitl.concurrent.ThreadPoolExecutor 类是线程池中最核心的一个类,因此如果要透彻地了解 Java 中的线程池,必须先了解这个类。
ThreadPoolExecutor 继承了 AbstractExecutorService 类,并提供了四个构造器。
构造器中所含的参数及其含义
corePoolSize:核心池的大小。在创建线程池后,默认情况下,线程池中的线程数量是0,当有任务来之后,就会创建一个线程去执行,当线程池中的线程达到corePoolSize之后,就会把之后到达的任务放到缓存队列当中。除非线程调用了prestartAllCoreThreads()或者 prestartCoreThread()方法,这两个方法是预创建线程的意思,即在没有任务到来之前就创建corePoolSize 个线程或者一个线程。
maximumPoolSize:线程池最大线程数。表示线程池中最多可以创建多少个线程。
keepAliveTime:表示线程没有执行时最多保持多长时间会终止。默认情况下,只有线程池中的线程数量大于corePoolSize时,keepAliveTime才会起作用。就是说,当线程池中的线程数大于 corePoolSize 时,如果一个线程空闲的时间达到 keepAliveTime,则会终止,直到线程池中的线程数不超过 corePoolSize。
until:参数keepAliveTime的时间单位,有7中取值。
workQueue:一个阻塞队列,用来存储等待执行的任务。
工作队列有ArrayBlockingQueue(基于数组,有界阻塞队列,创建时必须给定长度,按FIFO排序)和LinkedBlockingQueue(基于链表,按FIFO排序任务,容量可填,不填默认为最大长度)。
threadFactory:线程工厂,主要用来创建线程。
handler:表示当拒绝处理任务时的策略。
线程池的执行
创建完成ThreadPoolExecutor之后,当向线程池提交任务时,通常使用execute方法,execute方法执行流程如下图:
- 如果一个线程池存活的线程数量小于corePoolSize时,线程池会创建一个核心线程去处理提交的任务。
- 如果线程池中的线程数量等于核心线程数(corePoolSize),再提交一个任务,会被放进任务队列workQueue排队等待执行。
- 当线程池里面存活的线程数量已经等于核(corePoolSize),并且任务队列(workQueue)也满了,就会判断线程池中的线程是否达到maximumPoolSize最大线程数,如果没有达到,就创建一个非核心线程执行提交的任务。
- 如果当前线程池中的线程达到了最大线程数maximumPoolSize,还有新的任务过来的话,直接采取拒绝策略处理。
线程池的拒绝策略
默认解决策略有四种:
AbortPolicy策略:直接抛出异常,组织系统工作。
CallerRunsPolicy策略:只要线程池没有被关闭,如果任务被拒绝了,则由提交任务的线程直接执行此任务。
DiscardOleddestPolicy策略:丢弃最老的请求,也就是即将被执行的任务,并尝试再次提交当前任务。
DiscardPolicy 策略:丢去无法处理的任务,不做任何处理。
execute和submit的区别
都是用于执行任务的。
execute:没有返回值。
submit:有返回值。
ThreadLocal
ThreadLocal:线程变量,意思是ThreadLocal填入的变量属于当前线程的变量。
ThreadLocal为每个线程创建了一个副本,每个线程都可以访问和操作自己内部的副本数据。
//创建一个ThreadLocal对象,复制保用来为每个线程会存一份变量,实现线程封闭
private static ThreadLocal<Integer> localNum = new ThreadLocal<Integer>(){
@Override
protected Integer initialValue() {
return 0;
}
};
ThreadLocal的原理分析
ThreadLocal是一个泛型类,可以传入任何类型的数据。
ThreadLocal底层维护了一个Map集合(ThreadLocalMap),当ThreadLocal类型的数据发生set()和get()操作时,底层都是用到了ThreadLocalMap。
其set方法代码如下:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
当ThreadLocal类型数据执行set()操作时,首先通过Thread.currentThread();获得当前线程t,通过t获取线程的ThreadLocalMap属性map,然后判断map,如果map为空就通过createMap方法创建一个ThreadLocalMap,以当前的ThreadLocal为key,以set值为value。
总结:变量最终是放在了ThreadLocalMap上,而不是ThreadLocal上,ThreadLocal作为key存在。
ThreadLocal内存泄漏问题
ThreadLocalMap使用ThreadLocal的弱引用为key,如果ThreadLocal不存在外部强引用时,key值(ThreadLocal)肯定会被回收的,这样就导致了ThreadLocalMap中key为null,而value为强引用,会仍然存在,直到线程退出后,value的强引用链才会断开。
如果当前线程一直没有结束的话,这些key为null的Entry的value就会一直存在。永远无法回收。
ThreadLocal正确使用方法
每次使用完ThreadLocal都调用一次它的remove()方法清除数据。
public class ThreadLocalDemo {
//创建一个ThreadLocal对象,复制保用来为每个线程会存一份变量,实现线程封闭
private static ThreadLocal<Integer> localNum = new ThreadLocal<Integer>(){
@Override
protected Integer initialValue() {
return 0;
}
};
public static void main(String[] args) {
new Thread(){
@Override
public void run() {
localNum.set(1);
// localNum1.set(1);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
localNum.set(localNum.get()+10);
localNum.remove();
System.out.println(Thread.currentThread().getName()+":"+localNum.get());
}
}.start();
new Thread(){
@Override
public void run() {
localNum.set(3);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
localNum.set(localNum.get()+20);
System.out.println(Thread.currentThread().getName()+":"+localNum.get());//23
}
}.start();
System.out.println(Thread.currentThread().getName()+":"+localNum.get());//0
}
}