一、多线程基础
1、线程与进程
- 进程:资源管理的最小单位,一个进程内有多个线程。
- 线程:CPU调度的最小单位,一个线程就是一条指令流,由CPU调度一条条执行。
2、并行与并发
- 并发:单核CPU通过切换上下文执行多线程任务。
- 并行:多核CPU执行多线程任务,真正意义上的同一时刻运行。
3、线程的状态
线程一共有6种状态:
- New(新建)
- Runnable(可运行)
- Bolocked(阻塞)
- Waiting(等待)
- Timed waiting(计时等待)
- Terminated(终止)
4、线程的死锁
死锁发生在多个线程相互等待对方释放锁资源,导致所有线程都无法继续执行。
4.1 死锁是如何产生的
- 互斥条件:资源不能被多个线程共享,一次只能由一个线程使用。如果一个线程已经占用了一个资源,其他请求该资源的线程必须等待,直到资源被释放。
- 持有并等待条件:一个线程至少已经持有至少一个资源,且正在等待获取额外的资源,这些额外的资源被其他线程占有。
- 不可剥夺条件:资源不能被强制从一个线程中抢占过来,只能由持有资源的线程主动释放。
- 循环等待条件:存在一种线程资源的循环链,每个线程至少持有一个其他线程所需要的资源,然后又等待下一个线程所占有的资源。这形成了一个循环等待的环路。
4.2 如何避免死锁
想要避免死锁,至少需要破坏一个死锁发生的条件。
- 破坏互斥条件:这通常不可行,因为加锁就是为了互斥。
- 破坏持有并等待条件:一种方法是要求线程在开始执行前一次性地申请所有需要的资源。
- 破坏非抢占条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:对所有资源类型进行排序,强制每个线程按顺序申请资源,这样可以避免循环等待的发生。
二、多线程的使用
1、线程的创建
1.1 继承 Thread() 类
通过继承Thread() 类实现线程的创建是最简单的做法,但有部分局限性:
- 任务逻辑写在Thread类的run方法中,有单继承的局限性。
- 创建多线程时,每个任务有成员变量时不共享,必须加static才能做到共享(原因待考证)。
- 无返回值。
我们启动线程一定要调用 start() 方法,而不是 run() 方法。
调用 join() 方法表明:主线程阻塞,join()的调用线程执行完成后,主线程恢复执行。
public class MyThread extends Thread {
public MyThread() {
}
@Override
public void run() {
System.out.println("我继承了Thread实现多线程任务");
}
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
mt.join();
}
}
1.2 实现 Runnable() 接口
实现 Runnable() 接口的方式创建创建线程解决的单继承的局限性,但是同样无法接收返回值。
下面是一道简单例题:循环打印十次ABC
public class MyRunnable implements Runnable {
/**
* 循环打印ABC十次
*/
private final Object pre;
private final Object self;
private final String word;
public MyRunnable(String word, Object pre, Object self) {
this.word = word;
this.self = self;
this.pre = pre;
}
@Override
public void run() {
int count = 10;
while (count > 0) {
synchronized (pre) {
synchronized (self) {
System.out.print(word);
count--;
self.notifyAll();
}
try {
if (count > 0)
pre.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Object l1 = new Object();
Object l2 = new Object();
Object l3 = new Object();
MyRunnable mr1 = new MyRunnable("A", l3, l1);
MyRunnable mr2 = new MyRunnable("B", l1, l2);
MyRunnable mr3 = new MyRunnable("C", l2, l3);
Thread t1 = new Thread(mr1);
Thread t2 = new Thread(mr2);
Thread t3 = new Thread(mr3);
t1.start();
Thread.sleep(50);
t2.start();
Thread.sleep(50);
t3.start();
t1.join();
t2.join();
t3.join();
}
}
1.3 实现 Callable() 接口
实现 Callable() 接口能够接收返回值,但也有特殊的地方:
- 重写的是 call() 方法而不是 run() 方法。
- 线程的启动是将任务放入FutureTask<T>中,然后通过new Thread(ft).start() 启动。
public class MyCallable implements Callable<String> {
public MyCallable() {
}
@Override
public String call() throws Exception {
System.out.println("我实现了Callable接口实现多线程任务");
return "执行成功";
}
public static void main(String[] args) {
// 注意这里Callable实现多线程任务时如何启动
MyCallable mc = new MyCallable();
FutureTask<String> ft = new FutureTask<>(mc);
new Thread(ft).start();
}
}
2、线程的阻塞
这里聊一下几种简单的方式:
2.1 wait() + notify() / notifyAll()
- wait():当前线程进入阻塞状态。
- notify():随机将一个等待池中线程唤醒。
- notifyAll():唤醒等待池中的所有线程。
在使用Synchronized同步锁时,我们可以才可以使用上述方法。
同时,这种等待-唤醒的机制也可以用于线程的通信。
2.2 await() + signal() / signalAll()
这几个方法功能和上面的没有区别,但是这些是在Lock锁种使用。
2.3 yield()
yield()方法会让运行中的线程切换到就绪状态,重新争抢cpu的时间片,争抢时是否能重新获取到时间片要看cpu的分配。
3、线程的常用方法
这里是一些线程中常见的其他方法,比较简单。
三、线程池
除了上述的创建线程的方法,我们还可以通过使用线程池来创建和使用线程。
1、什么是线程池
线程池可以理解为一个“池子”,里面装有已创建好的线程,当我们需要使用线程时从“池子”中取出线程直接使用,使用完毕后将线程放回“池子”等待再次被使用。线程池具有以下优点:
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以对线程进行统一的分配,调优和监控。
线程池只会在创建线程的时候判断核心线程,只要线程被创建出来了,线程之间不做区分,只保留最大核心线程数量的线程。
下面是线程池的具体运行流程:
2、线程池的创建
通过 ThreadPoolExecutor 类创建线程,下面是其最重要的构造方法代码
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0) {
throw new IllegalArgumentException();
}
if (workQueue == null || threadFactory == null || handler == null) {
throw new NullPointerException();
}
this.acc = System.getSecurityManager() == null ? null : AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
这个构造方法共有7个参数:
- corePoolSize:核心线程数。
- maximumPoolSize:最大线程数。
- keepAliveTime:非核心线程空闲存活时间。
- unit:时间单位。
- BlockingQueue<Runnable> workQueue:阻塞队列。
- ThreadFactory threadFactory:线程工厂,用于创建线程。
- RejectedExecutionHandler handler:拒绝策略(后面详细讲解)。
3、常见的线程池
3.1 定长线程池:newFixedThreadPool
FixedThreadPool 线程池的核心线程数和最大线程数是一样的,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的。如果任务数超过线程数,线程池会把超出的任务放到任务队列中进行等待。如果任务队列满了,则会执行拒绝策略。
// 定长线程池的创建
ExecutorService cachedThreadPool = Executors.newFixedThreadPool(3);
// 底层创建逻辑
public static ExecutorService newFixedThreadPool(int nThreads) {
// 这里可以看到核心线程数和最大线程数一样,所以不会创建非核心线程
// 使用的是默认拒绝策略和默认线程创建工厂Executors.defaultThreadFactory()
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
3.2 单一线程池:SingleThreadExecutor
它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
// 单一线程池的创建
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
// 底层创建逻辑
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
// 核心线程和最大线程数都为1
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
3.3 可缓存线程池:CachedThreadPool
CachedThreadPool 可缓存线程池,它线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。当我们提交一个任务后,线程池会判断已创建的线程中是否有空闲线程,如果有空闲线程则将任务直接指派给空闲线程,如果没有空闲线程,则新建线程去执行任务,这样就做到了动态地新增线程。
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
// 这里可以看到,核心线程数为0,意味着如果超过空闲时间,线程池中的线程可能全部被销毁
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
public class ThreadPoolExecutorDemo {
public static void main(String[] args){
//创建可缓存线程池
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
try {
//sleep可明显看到使用的是线程池里面以前的线程,没有创建新的线程
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
cachedThreadPool.execute(new Runnable() {
@Override
public void run() {
//打印正在执行的缓存线程信息
System.out.println(Thread.currentThread().getName() + "正在被执行1");
}
});
}
}
}
3.4 周期性线程池:ScheduledThreadPool
newScheduledThreadPool 可用于创建可定时调度的线程池,可设置在给定延迟时间后执行或定期执行某个线程任务。
public class Test{
public static void main(String[] args) {
ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);
// 创建一个延迟3秒执行的线程
pool.schedule(new Runnable() {
public void run() {
System.out.println("delay 3 seconds" + Thread.currentThread().getName());
}
}, 3, TimeUnit.SECONDS);
// 创建一个延迟3秒且每1秒执行一次的线程
pool.scheduleAtFixedRate(new Runnable() {
public void run() {
System.out.println("delay 3 second and repeat execute every 1 seconds" + Thread.currentThread().getName());
}
}, 3, 1, TimeUnit.SECONDS);
// 关闭线程池
pool.shutdown();
}
}
以上四种线程池都不推荐使用
3.5 自定义线程池
4、线程池的拒绝策略
4.1 四种默认拒绝策略
- ThreadPoolExceutor.AbortPolicy 丢弃任务并抛出RejectedExecutionException异常。
- ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
- ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
- ThreadPoolExecutor.CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务
4.2 自定义拒绝策略
四、线程安全
1、线程的三大特性
1.1 原子性
多个操作作为一个整体,不能被分割与中断,也不能被其他线程干扰。
1.2 可见性
一个线程修改的共享变量,其他线程能够立刻看到。
1.3 有序性
程序按照代码的先后顺序来执行,不会发生指令重排序。
2、常用的锁
2.1 CAS 自旋锁
CAS即Compare and Swap,比较并替换。
CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
从思想上来说,synchronized属于悲观锁,悲观的认为程序中的并发情况严重,所以严防死守,CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去重试更新。synchronized转变为重量级锁之前,也会采用CAS机制。而我们熟知的AQS底层也是通过CAS和Synchronized来保证线程安全的。
CAS的缺点:
- CPU开销过大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力。
- 无法保证代码块的原子性:CAS机制只能保证一个变量的原子性操作,而不能保证整个代码块的原子性。
- ABA问题:假设t1线程工作时间为10秒,t2线程工作时间为2秒,那么可能在A的工作期间,主内存中的共享变量 A已经被t2线程修改了多次,只是恰好最后一次修改的值是该变量的初始值,虽然用CAS判定出来的结果是期望值,但结果却是错的。(通过添加版本号解决)
2.2 Synchronized
Synchronized是Java中的关键字,该关键字是依靠JVM来进行识别,是虚拟机级别的(区别于Lock锁API级别的)。
synchronized是隐式锁,不需要手动开启和关闭锁;synchronized有代码块锁和方法锁。
同步锁也叫对象锁,是锁在对象上的,不同的对象就是不同的锁。
让同一个时刻最多只有一个线程能持有对象锁,其他线程在想获取这个对象锁就会被阻塞,不用担心上下文切换的问题。
注意:锁是加在对象上的,须确保是同一对象,锁才能生效
Synchronized有一个锁升级过程:
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
锁可以升级不能降级,但轻量级锁状态可以被重置成无锁状态。
2.3 ReentrantLock
Lock锁是API级别的,提供了相应的接口和对应的实现类。这种方式实现锁会更加的灵活(区别于synchronized锁是JVM级别的)。
Lock锁是显式锁,需要手动开启和关闭锁(别忘了最后一定要关闭锁);Lock锁只有代码块锁。
// count++ 不是原子性操作, 所以volatile不能保证线程安全
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 可重入锁,建议撸一遍源码
Lock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 500000; i++) {
lock.lock();
try {
count++;
} catch (Exception e){
e.printStackTrace();
} finally {
lock.unlock();
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 500000; i++) {
lock.lock();
try {
count--;
} catch (Exception e){
e.printStackTrace();
} finally {
lock.unlock();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
2.4 Volatile
Volatile是Java中的一个关键字,它常被称为轻量级锁,在某些情况下也可以用于保证线程安全。
2.4.1 Volatile底层原理
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
当程序在运行过程中,会将运算所需要的数据从主内存
中拷贝一份到高速缓存
(私有的本地内存—Local Memory)中以便提高运算效率,但在多线程情况下会造成缓存一致性问题(通常称这种被多个线程访问的变量为共享变量
)。
2.4.2 volatile的两大特性
- 保证可见性
(1)当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。
(2)这个写操作会导致其他线程中的volatile变量缓存无效。
- 禁止指令重排序
(1)重排序操作不会对存在数据依赖关系的操作进行重排序。
比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
(2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。
但是,想要保证线程安全要同时满足原子性
、可见性
以及有序性
,所以volatile只适用于原子性操作中(如i++不是原子性操作,不能使用volatile,可用synchronize、AtomicInteger等)。 volatile 常用于多线程环境下的单次操作(单次读或者单次写)。
2.4.3 volatile的使用条件
使用重量级锁可以保证线程安全,但是会影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized等锁,但是要注意volatile是无法替代synchronized的,因为volatile无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
3、线程本地变量 ThreadLocal
保证多线程安全的方式是不让多线程去操作临界资源,每个线程去操作属于自己的数据。
public class MyThreadLocal {
static ThreadLocal tl1 = new ThreadLocal();
static ThreadLocal tl2 = new ThreadLocal();
public static void main(String[] args) {
tl1.set("main1");
tl2.set("main2");
new Thread(() -> {
tl1.set("thread1");
tl2.set("thread2");
System.out.println("thread" + tl1.get());
System.out.println("thread" + tl2.get());
}).start();
System.out.println("main" + tl1.get());
System.out.println("main" + tl2.get());
}
}
实现原理:
// 通过ThreadLocal将数据存在ThreadLocalMap中。
// ThreadLocalMap 是一个内部类,用一个Entry(弱引用,解决了key的内存泄漏问题)数组通过key-value的形式存储数据。
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
ThreadLocal内存泄漏问题:
- 原因:如果ThreadLocal引用丢失,key(ThreadLocal对象)因为是弱引用会被回收掉,但是如果线程还未被回收,key对应的value未被回收,导致内存泄漏。
- 解决方案:使用完ThreadLocal对象后,掉用remove方法,移除Entry即可。