前言
本篇博客主要是对Java并发编程重要知识点的总结。包括线程、进程、线程池、Java锁、重要关键字说明以及之间的对比。
一、线程
1.线程和进程
程序 = 指令 + 数据。
进程:
创建:当一个程序被运行,从磁盘加载这个程序代码至内存,这时就开启了一到多个进程。
作用:加载指令、管理内存、IO等。
程序与进程的关系:大部分程序可以同时运行多个进程,也有程序只能启动一个实例进程。
地位:进程作为资源分配的最小单位。
线程:
一个进程之内可以分为一到多个线程。
作用:一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行。
地位:线程作为最小调度单位。
进程 VS 线程:
1.进程基本上相互独立。而线程存在与进程内,是进程的一个子集。
2.进程拥有共享的资源,如内存空间等,供其内部线程共享。线程只独享指令流执行的必要资源。
3.进程间的通信比较复杂。线程通信相对简单,它们共享进程内的内存。
4.线程更轻量,线程上下文切换成本一般比进程上下文切换低。
2.并发和并行
并发: 单核CPU下,同一时刻轮流使用CPU的做法叫做并发。
并行: 多核CPU下,每个核都可以调度运行线程,这时线程是并行的。
多核CPU下 并发 和 并行 共存。多核CPU下多线程并行才可以提高效率,单核CPU下多线程微观上还是串行的。
3.创建线程的方法
(1)继承Tread类。
(2)实现Runnable/Callable接口的方式。
Runnable VS Callable:
Callable规定重写方法是call(),可以抛出异常,任务执行后有返回值。Runnable规定重写方法是run(),不能抛出异常,执行后没有返回值。
(3)通过线程池的方式
4.线程的状态
4.1 操作系统层面
创建: 仅在语言层面创建了线程对象,还未与操作系统线程相关联。
就绪: 该线程已被创建且与操作系统相关联,可以由CPU调度执行。
运行: 获取了CPU时间片并处于运行代码的状态。
阻塞: 调用了阻塞API(如读写文件),导致线程上下文切换,进入阻塞状态。等待操作完毕,会由操作系统唤醒阻塞线程转至可运行。
终止: 线程已经执行完毕,生命周期已经结束。
4.2 JavaAPI层面
new: 线程刚被创建,但还没有调用start()方法。
Runnable: 调用了start()方法之后,此时线程才能真正执行。
Java API层面的 Runnable状态 涵盖了操作系统层面的 可运行、运行和操作系统的阻塞状态。
(Blocked、Waiting、Timed_Waiting都是Java对阻塞状态的细分)
Blocked: 阻塞状态,表示线程阻塞与锁。
Waiting: 等待状态,表示当前线程需要等待其他线程做出通知或中断。
Time_Waiting: 超时等待状态,该状态不同于Waiting的地方是,可以在指定的时间自行返回。
Terminated: 终止状态,表示当前线程已经执行完毕。
5.常见方法
5.1 start & run
run方法称为线程体,直接调用run方法,并不能启动新的线程。启动线程必须调用start方法,同一个线程只能调用一次start。
5.2 sleep VS yield
(Tread.sleep(n)在哪个线程调用,该线程睡眠)
sleep:
(1)调用sleep方法会让线程从Runnable到Timed_Waiting。
(2)其他线程可以使用interrupt方法打断正在睡眠的线程,这时会抛出InterruptedException异常。
(3)睡眠结束后的线程未必会立即执行,需要重新争夺CPU,获取到时间片后才能执行。
yield:
(1)调用yield方法让当前线程让出CPU,状态变为可运行,然后调度执行其他线程。
(2)可能立即又被分配到时间片。
5.3 sleep VS wait
共同点:都是让当前线程放弃CPU,进入阻塞状态。
不同点:
(1)方法归属不同
sleep()是Tread的静态方法。
wait()是Object的成员方法,每个对象都有。
(2)醒来的时机不同
执行sleep(long)和wait(long)的线程都会在等待相应毫秒后想来
wait(long)和wait()还可以被 notify 唤醒。
都可以调用interrupt方法打断唤醒。
(3)锁特性不同
wait方法的调用必须先配合synchronized一起获得到wait对象的锁。
wait方法执行后会释放对象锁,允许其他线程获得对象锁。
而sleep方法没有释放锁
6.线程的活跃性
(1)死锁:互相等待对方已经持有的锁,参与的线程都会被阻塞。
解决:顺序加锁(容易产生 饥饿)、ReentrantLock(tryLock)
(2)活锁:出现在两个线程互相改变对方的结束条件,最后谁也无法结束,但并未阻塞。
解决:让它们的执行时间有一定的交错(增加随机睡眠)
(3)饥饿:一个线程由于优先级太低,始终得不到CPU调度的执行,也不能够结束。
解决:ReentrantLock设置公平锁
7.守护线程
默认情况下,Java进程需要等待所有线程都运行结束,才会结束。
有一种特殊的线程 守护线程,为用户线程提供公共服务,只要其他非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。守护线程中产生的新线程也是守护线程。
实现:在启动之前将 线程.setDaemon(true),该线程即为守护线程。
应用:垃圾回收器线程,当垃圾回收器线程是JVM上仅存的线程时,垃圾回收器线程会自动离开。
8.终止线程的四种方式
1.正常运行结束。
2.自定义退出标志退出线程。
3.interrupt()方法结束线程。
(1)线程处于阻塞状态,调用interrupt方法会抛出InterruptException异常,然后break才能跳出循环结束。
(2)线程未处于阻塞状态:使用isInterrupted()判断线程的中断标志来退出循环。
4.stop方法终止线程。
调用stop后导致该线程持有的锁的突然释放,锁保护的数据就可能呈现不一致性。是线程不安全的。
二、锁
1.线程安全
线程安全:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
1.1 临界区
一段代码内如果存在对 共享资源 的多线程读写操作成这段代码块为临界区。
如何实现线程安全?
阻塞式(锁synchronized、lock)、非阻塞式(原子变量)。
1.2 变量的线程安全分析
成员变量和静态变量是否线程安全:
(1)如果过他们没有被共享,则线程安全。
(2)如果他们被共享,如果只有读操作,线程安全。如果有读写操作(临界区),需要考虑线程安全。
局部变量是否线程安全:
(1)局部变量是线程安全的。
(2)局部变量引用的对象未必线程安全。如果该对象没有逃离该局部的范围,是线程安全的。否则需要考虑线程安全。
1.3 JMM
原子性
原因:多线程下,不同线程的 上下文切换 引起指令交错,导致的共享变量的读写混乱。
解决:synchronized、Lock、CAS(volatile无法保证原子性)
可见性
原因:由于编译器优化、缓存优化或者CPU指令重排序优化导致的对共享变量所做的修改另外的线程看不到。
解决:volatile
有序性
原因:由于编译器优化、缓存优化或者CPU指令重排序优化导致指令的时机执行顺序与编写顺序不一致。
解决:volatile
1.4 常见的线程安全类
String、Integer等的包装类。StringBuffer、Random、Vector、Hashtable、JUC包下的类。
2. 两种锁的对比
2.1 synchronized
使用 对象锁 保证了 临界区 代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换打断。
**线程八锁:**考察synchronized锁住的是哪个对象。
1.作用与成员变量时,锁住的是this对象。
2.方法上的synchronized
(1)成员方法:相当于锁住的是this对象。
(2)静态方法:相当于锁住的是类对象class。
3.synchronized作用于一个代码块时,锁住的是所有代码块中配置的对象。
synchronized锁升级
锁状态:无锁状态、偏向锁、轻量级锁、重量级锁。
(1)无锁
当一个对象被创建之后,还没有线程进入,这个时候对象处于无锁状态,其Mark Word中的信息如上表所示。
(2)偏向锁:
经过研究发现,大多数情况下不存在锁的竞争,常常是一个线程多次获得同一个锁。这种情况下,每次锁竞争会付出很多不必要的代价,为了降低获取锁的代价,引入了偏向锁。
重偏向:若对象虽然被多个线程访问,但没有竞争,这时偏向某线程的对象仍有机会偏向其他线程,重偏向会重置对象的Tread ID。
批量重定向:当撤销偏向锁阈值超过20次之后,JVM觉得可能偏向了一个不适合的线程,于是会在给这些对象加锁时重偏向至现加锁线程,而不是升级为轻量锁。
批量撤销:当撤销偏向锁阈值超过40次之后,JVM会觉得确实偏向错了,根本就不该对该类对象进行偏向,于是该类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的,锁升级为轻量级锁。
(3)轻量级锁
考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景,减少传统的重量级锁加锁、解锁产生的性能消耗。通过CAS实现。
自旋锁:通过CAS尝试加轻量级锁,如果CAS操作成功,则加锁成功。如果CAS失败,则认为锁被占用,继续自旋式的等待。(多核CPU下自旋才能发挥优势)
锁膨胀:若在尝试加轻量级锁的过程中,cas操作无法成功,且自旋已经达到了一定的次数,这时需要进行锁膨胀,将轻量级锁升级为重量级锁。
(4)重量级锁
依赖操作系统的Mutex Lock实现的,这时线程切换就需要从用户态装换到和心态,成本高。在内核态判定当前锁是否已经被占用,如果没有,则加锁成功并切换回用户态。否则,加锁失败,线程进入锁的等待队列(EntryLIA)
总结
轻量级锁对于重量级锁有了一定的优化,但是轻量级锁在没有竞争的时候,每次冲入仍然要指向cas操作,Java6中引入了偏向锁做进一步优化。
其他的锁优化
锁消除
JVM在JIT编译优化时,通过对上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,节省毫无意义的加、解锁的资源消耗。
锁粗化
一段逻辑中如果出现多次加锁、解锁,其本身会消耗资源,不利于性能的优化。会自动进行锁的粗化。
2.2. ReentrantLock(JUC)
ReentrantLock是一种可重入锁,除了能完成synchronized所能完成的所有工作外,还提供了 可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
可重入锁
同一线程如果首次获得了这把锁,因为它是锁的持有者,有权再次获取这把锁。
不可重入锁
第二次获得锁的时候自己也被挡住。
非公平锁
ReentrantLock在构造函数中提供了是否公平锁的初始化方式,默认是非公平锁。
JVM按随机分配锁的机制吃呢个位非公平锁。非公平锁实际的执行效率远远超出公平锁,除非有特殊需要才会使用公平锁。
公平锁
锁的分配机制是公平的,通常先对锁提出请求的线程会先被分配到锁。
2.3 JUC下的锁 VS synchronized
共同点:
(1)都是用来协调多线程对共享对象、变量的访问。
(2)都是可重入锁。
(3)都保证了 可见性。
不同点:
(1)JUC下的锁 需要显示的获得、释放锁。synchronized 隐式的获得释放锁。synchronized 在发生异常时,会自动释放线程战=占有的锁,前者发生异常时没有主动释放锁,可能会造成死锁现象。
(2)JUC下的锁 书写更加灵活,可以一个方法加锁,另一个方法中解锁。
(3)JUC下的锁 是API级别的。synchronized 是JVM级别的。
(4)JUC下的锁 锁的类型更加灵活。synchronized 只有一种类型的锁。
(5)JUC下的锁可以让等待锁的线程响应中断,而synchronized 不行。
3. volatile
3.1 原理
volatile的底层时间原理是内存屏障
(1)对volatile变量的写指令之后会加入写屏障。
(2)对volatile变量的读指令之前会加入读屏障。
如何保证 可见性 ?
(1)写屏障之前保证在该屏障 之前 的对共享变量的改动,都同步到主存中。
(2)读屏障保证在该屏障 之后 的对共享变量的读取加载的都是内存中最新的数据
如何保证 有序性?
(1)写屏障会确保指令重排时,不会将写屏障之前的代码排在写屏障之后。
(2)读屏障会确保指令重排时,不会将读屏障之后的代码排在读屏障之前。
内存屏障并不能解决原子性问题(指令交错):
(1)写屏障仅仅保证之后的读能够读到最新数据,而不能保证读跑到它前面去。
(2)有序性的保证也只能保证是本线程内的相关代码不被重排序。
4.CAS
4.1 CAS特点
(1)CAS可以保证 原子性。
(2)CAS + volatile 可以实现无锁并发,适用于线程少、多核CPU下。
(3)CAS是基于乐观锁的策略实现的。
(4)CAS体现的是无锁并发。
1)没有使用synchronized等锁,线程不会陷入阻塞。
2)如果竞争激烈,重尝试必然频繁发生,不利于线程。
4.2 乐观锁 和 悲观锁 思想
乐观锁
核心思想:无需加锁,每次只有一个线程能成功修改共享变量,其他失败的线程不需要停止,不断重试直至成功。
代表:CAS
悲观锁
核心思想:线程只有占有了所,才能去操作共享变量,每一次都只有一个线程成功。获取锁失败的线程,都得停下来等待。
代表:synchronized 和 Lock 锁
4.3 使用CAS实现的工具类(JUC)
(1)原子整数:AtomicBoolean、AtomicInteger、AtomicLong
(2)原子引用:AtomicReference、AtomicMarkableReference、AtomicStampedReference.
(3)原子数组:AtomivIntegerArray、AtomicLongArray、AtomicReferenceArray
4.4 CAS导致的ABA问题
线程只能判断共享变量的值与最初值是否相同,不能感知到A->B->A的情况。
解决: 加一个版本号
5. 其他
ReadWriteLock 读写锁
为了提高性能,在读的地方使用读锁,在写的地方使用写锁,灵活控制,在一定程度提高效率。
多个读锁不互斥、读锁与写锁互斥。
独占锁 和 共享锁 加锁模式
独占锁模式:每次只能有一个线程持有锁,是一种悲观的加锁策略。
共享锁模式:允许多个线程同时获取锁,并发访问共享资源。
三、Java阻塞队列
1. Java阻塞队列原理
生产者-消费者
1.当队列中没有数据的情况下,消费者段所有线程都会被挂起,直到有数据放入队列。
2.当队列填满数据的情况下,生产者段的所有线程都会被挂起,直到队列中有空位,线程被自动唤醒。
2.主要方法
方法类型 | 抛出异常 | 返回特殊值 | 超时 |
---|---|---|---|
插入 | add(e) | offer(e) | put(e) |
移除 | remove() | poll() | take() |
3. Java中常见的阻塞队列
1.ArrayBlockingQueue:由数组结构组成的有界阻塞队列。
2.LinkedBlockingQueue:由链表组成的有界阻塞队列。
3.PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
4.DelayQueue:是一个实现PriorityBlockingQueue的延迟获取的无界队列。
5.SynchronousQueue:不存储任何元素的阻塞队列,每一个put操作必须等待take操作,可用于传递数据。
四、线程池
线程池特点:线程复用、控制最大并发数、管理线程。
1.线程池的基本组成
(1)线程池管理器:用于创建并管理线程池。
(2)工作线程:线程池中的线程。
(3)任务接口:每个任务必须实现的接口,用于工作调度其运行。
(4)任务队列(阻塞队列):用于存放待处理的任务,提供一种缓冲机制。
2.线程池的创建
(1)通过Executors工厂类创建,创建方式较简单,但定制能力有限。
(2)通过ThreadPoolExcutor创建,创建方式比较复杂,但定制能力强。
3.Excutors创建的四种线程池
线程池的顶级接口是Excutors,真正的线程池接口是ExcutorService.
3.1 newCachedThreadPool
线程池大小不固定,可灵活回收空闲线程。若无可回收,则新建线程。
3.2 newFixedThreadPool
固定大小的线程池,当有新任务提交,线程池中如果有空闲线程,则立即执行。否则新任务会被缓存在一个任务队列中,等待线程池释放空闲线程。
3.3 newSechedThreadPool
定时线程池,支持定时及周期性任务执行。
3.4 newSingleThreadPool
只创建一个线程,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。
4. ThreadPoolExecutor 线程池7大核心参数
1.**corePoolSize:**指定线程池中的线程数量。
2.**maximumPoolSize:**指定了线程池中最大线程数量。
3.**keepAliveTime:**多余线程的存活时间,当前线程数大于 corePoolSize,并且等待时间大于 keepAliveTime,多余线程可能被销毁直到剩下corePoolSize为止。
4.TimeUnit unit: keepAliveTime的时间单位。
5.**workQueue:**任务队列,被提交但未被执行的任务。
6.**threadFactory:**线程工厂,用于创建线程。
7.**handler:**拒绝策略,当任务队列满了并且工作线程大于线程池的最大线程数,如何拒绝任务。
4.1 拒绝策略
1.AbortPolicy:线程池的默认拒绝策略。直接丢弃任务并抛出异常。
2.DiscardPolicy:丢弃任务,但不抛出异常。
3.DiscardOldestPolicy:丢弃任务队列最前面的任务,不抛出异常,然后重新提交当前任务。
4.CallerRunsPolicy:由调用线程处理该任务。任务提交线程性能极有可能几句下降。
4.2 Java线程池的工作原理
1.线程池创建时,里面没有一个线程,任务队列作为参数传入。
2.当调用 execute() 方法添加一个任务时,线程池有如下判断:
(1)如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务,使用核心线程数。
(2)如果正在运行的线程数量大于或等于 corePoolSize,将这个任务队列放入任务队列。
(3)如果这时候队列满了,而且正在运行的线程数小于 maximumPoolSize ,那么创建非核心线程执行这个任务。
(4)如果队列满了,而且正在运行的线程数量等于 maximumPoolSize,那么线程池,就执行相应的拒绝策略。
3.当一个线程完成任务后,从队列中取下一个任务来执行。
4.当一个线程空闲,超过 keepAliveTime 时,线程会判断,如果当前运行的线程数大于corePoolSize ,那么该线程会被销毁。当线程池中所有任务都执行完毕后,线程数最大会收缩到 corePoolSize 大小。
补充:Hashtable VS ConcurrentHashMap
相同点:Hashtable 和 ConcurrentHashMap 都是线程安全的Map集合。
不同点:
Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时间,这能有一个线程操作它。ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,就不会冲突。
ConcurrentHashMap 1.7 VS 1.8
ConcurrentHashMap1.7
饿汉式初始化。
数据结构:Segment(大数组) + HashEntry(小数组) + 链表。每个segment对应一把锁,多个线程访问不同的segment不会冲突。
并发度:segment数组的个数决定了同时多少个线程访问。
扩容条件:小数组在超过扩容因子时会触发扩容,每次扩容翻倍。
ConcurrentHashMap1.8
懒汉式初始化。
数据结构:Node数组 + 链表 + 红黑树,数组的每个头结点作为锁,如果多个线程访问的头结点不同,不会产生冲突。
并发度:Node数组的大小。Node数组可以扩容。
扩容条件:Node数组的元素个数满 3 / 4 时就会扩容,容量翻倍。