阅读 6
傻瓜源码-内容简介 |
---|
【职场经验】(持续更新) 如何日常学习、如何书写简历、引导面试官、系统准备面试、选择offer、提高绩效、晋升TeamLeader..... |
【源码解读】(持续更新) 1. 源码选材:Java架构师必须掌握的所有框架和类库源码 2. 内容大纲:按照“企业应用Demo”讲解执行源码:总纲“阅读指南”、第一章“源码基础”、第二章“相关Java基础”、第三章“白话讲源码”、第四章“代码解读”、第五章“设计模式”、第六章“附录-面试习题、相关JDK方法、中文注释可运行源码项目” 3. 读后问题:粉丝群答疑解惑 |
已收录: HashMap 、 ReentrantLock 、 ThreadPoolExecutor 、 《Spring源码解读》 、 《Dubbo源码解读》 ..... |
【面试题集】(持续更新) 1. 面试题选材:Java面试常问的所有面试题和必会知识点 2. 内容大纲:第一部分”注意事项“、第二部分“面试题解读”(包括:”面试题“、”答案“、”答案详解“、“实际开发解说”) 3. 深度/广度:面试题集中的答案和答案详解,都是对齐一般面试要求的深度和广度 4. 读后问题:粉丝群答疑解惑 |
已收录: Java基础面试题集 、 Java并发面试题集 、 JVM面试题集 、 数据库(Mysql)面试题集 、 缓存(Redis)面试题集 ..... |
【粉丝群】(持续更新) 收录:阿里、字节跳动、京东、小米、美团、哔哩哔哩等大厂内推 |
:stuck_out_tongue: 作者介绍:Spring系源码贡献者、世界五百强互联网公司、TeamLeader、Github开源产品作者 :stuck_out_tongue: 作者微信:wowangle03 (企业内推联系我) |
当我们启动执行一个 Java 程序时,操作系统会创建 1 个 Java 进程,而这个 Java 进程至少会创建 1 个线程真正执行程序。所以进程是程序的一次执行过程;线程是进程的实际执行单元。进程之间系统资源互不影响;线程之间共享进程资源。
多线程处理任务,并不是真的并发执行;而是 CPU 给每个线程分配一段时间来执行自己任务(这段时间称之为 CPU 时间片,也可以说是 CPU 使用权),就算线程当前分配的任务没有处理完成,但是时间片用完了,也会切换到另一个线程;只不过,在切换前,CPU 会把任务的执行状态记录下来,好让线程重新获得时间片时,能够继续执行。
线程上下文切换的过程中,分配的 CPU 时间片和切换所用时间都非常短,所以用户是感知不到任务的切换,只会觉得是多个线程并发执行。但是因为线程的上下文切换本身也需要成本,所以对多线程的执行效率还是有一定影响的。
创建线程”本质“上有以下 3 种方式。引申出来的方式还有:线程池等。
需要注意,只有使用第 3 种方式,才可以获取线程执行任务的返回值。
class ThreadTest { public static void main(String[] args) throws Exception { // 1.执行 main 方法,启动主线程 // 2.主线程创建新线程对象 thread MyThread thread = new MyThread(); // 3.主线程启动 thread 线程,thread 线程执行 run() 方法中定义的逻辑 thread.start(); // 4.主线程运行结束 System.out.println("主线程执行结束!"); } } class MyThread extends Thread { /** * 用于封装线程执行的逻辑 */ @Override public void run() { try { // 模拟任务执行耗时 Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("test thread!"); } } 复制代码
打印结果:
主线程执行结束! test thread!
class ThreadTest { public static void main(String[] args) { // 1.执行 main 方法,启动主线程 // 2.主线程初始化线程的执行体 runnable MyRunnable runnable = new MyRunnable(); // 3.主线程启动 thread 线程,thread 线程执行 run() 方法中定义的逻辑 new Thread(runnable).start(); // 4.主线程运行结束 System.out.println("主线程执行结束!"); } } class MyRunnable implements Runnable { /** * 用于封装线程执行的逻辑 */ @Override public void run() { try { // 模拟任务执行耗时 Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("test thread!"); } } 复制代码
打印结果:
主线程执行结束! test thread!
class ThreadTest { public static void main(String[] args) throws Exception { // 1.执行 main 方法,启动主线程 // 2.主线程创建用于执行任务的 callable 对象 MyCallable callable = new MyCallable(); // 3.主线程创建用于获取返回值的 FutureTask 对象 FutureTask<String> result = new FutureTask(callable); // 4.主线程启动 thread 线程,thread 线程执行 call() 方法中定义的逻辑 new Thread(result).start(); // 5.主线程调用 FutureTask.get 方法,获取 thread 线程的返回结果;如果线程没有执行完任务,get 方法则会阻塞,直到有返回结果 System.out.println(result.get()); // 6.主线程运行结束 System.out.println("主线程执行结束!"); } } class MyCallable implements Callable<String> { /** * 用于封装线程执行的逻辑 */ @Override public String call() { try { // 模拟任务执行耗时 Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } return "test thread!"; } } 复制代码
打印结果:
test thread! 主线程执行结束!
执行耗时长,并且不希望等待方法返回的,通常使用异步的方式处理。
只是用于封装线程执行的逻辑,并不包含启动新线程的逻辑。
**2. start() **
用于启动新线程,新线程执行 run() 方法中定义的逻辑。
守护线程和非守护线程的区别就在于:系统运行结束时,JVM 会等待非守护线程执行结束后再关闭,但是 JVM 不会等待守护线程。例如:JVM 中的垃圾回收线程就是守护线程。
通过线程池等方式创建的线程都默认是非守护线程。
在 Java 8 中,线程一共有 6 个状态,分别是:
在多线程的场景下,程序的执行结果与单线程场景下一致。
默认情况下,是线程不安全的。
因为 Spring 管理的 bean 默认作用域是单例的,所以 Spring 创建的 Controller 默认也是单例的。如果是单例的对象,所有请求都会共享同一个 Controller 对象,也就是说所有请求都共享同一个 Controller 对象的成员变量;这时,如果这个成员变量是有状态的(有状态就是说有数据存储功能的变量),就会引发线程安全问题。
我们可以通过 Spring 将 Controller 作用域设置成原型模式,这样做,每个线程都有自己独立的 Controller 对象,就不会引发线程安全问题了。
实际开发中,我们使用的 Controller 就是单例模式的,的确不是线程安全的,但是开发者不会在 Controller 层写有状态的共享变量,所以不会引发线程安全问题。
是线程不安全的。
Servlet 在 Web 容器中的实例对象是单例的,故也有相同问题。
ThreadPoolExecutor 线程池是 JDK 并发包(java.util.concurrent)里的类;就是通过统一的方式创建线程,并进行管理,使得线程能够重复使用,支持更多功能。
实际开发中,使用 ThreadPoolExecutor (企业自己会封装线程池工具)或者 ThreadPoolTaskExecutor(Spring 中的)创建多线程。
public class ThreadPoolExecutorTest { @Test public void testThreadPoolExecutor() throws ExecutionException, InterruptedException { // 初始化线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor( 1, // 指定 corePoolSize (核心线程数) 1, // 指定的 maximumPoolSize (线程池可以创建的最大线程数) 0, // 指定的 keepAliveTime(线程空闲的情况下,可以存活的时间) TimeUnit.SECONDS, // keepAliveTime 的时间单位 new LinkedBlockingQueue<Runnable>()// 指定的 workQueue 对象(装任务的队列) ); // 初始化任务 Task task = new Task(); // 向线程池提交任务 // 打印结果: Task 任务被执行! executor.execute(task); // 关闭线程池有以下两种方法: // 关闭线程池: // 1.线程池不会接受新的任务,执行拒绝任务处理器(默认是抛出异常) // 2.线程池执行完已经提交了的任务再结束 executor.shutdown(); // 关闭线程池: // 1.线程池不会接受新的任务,执行拒绝任务处理器(默认是抛出异常) // 2.正在执行任务的线程不会被终止,直到任务处理完毕; // 3.任务队列中没有线程处理的等待任务,会直接被终止; // 4.返回未执行的任务 executor.shutdownNow(); } } class Task implements Runnable { @Override public void run() { System.out.println(" Task 任务被执行!"); } } } 复制代码
用于控制线程池逻辑:当用户提交任务时,只要工作线程数小于 corePoolSize 时,就会创建工作线程执行任务(工作线程是指线程池创建的存活线程);如果等于 corePoolSize,就不会创建工作线程,而是将任务放到 workQueue 队列对象里。
核心线程只是一个概念,在代码中并没有标记某个线程是否是核心线程;也就是说当工作线程数小于等于 corePoolSize 时,线程池中这部分工作线程就是核心线程。
表示线程空闲的情况下,可以存活的时间;核心线程不受 keepAliveTime 控制;除非 allowCoreThreadTimeOut 置为 true
表示是否允许核心线程空闲超时,默认为 false。如果配置 true,核心线程也会受 keepAliveTime 控制。
当工作线程数等于 corePoolSize 时,提交的任务就会放在 workQueue 指定的队列对象里。
表示线程池中最多能够容纳多少工作线程数。
当工作线程数等于配置的 corePoolSize,并且 workQueue 已满时,线程池会创建新的非核心线程,直到线程池中的工作线程总数等于 maximumPoolSize。
拒绝任务处理器,默认是抛出异常;如果【任务队列已满,并且工作线程总数等于 maximumPoolSize 】或者【已经调用 shutdown() 方法或 shutdownNow() 方法】,向线程池提交任务,则会执行 handler 。
线程池可以减少性能开销、减少资源浪费、支持更多功能。
常用的参数有 6 个:
execute() 和 submit() 都是用来提交任务的,区别在于:submit() 方法可以返回线程执行任务的返回值。
使用线程池时,都可能会用到。
public class ThreadPoolExecutorTest { @Test public void testThreadPoolExecutor() throws ExecutionException, InterruptedException { // 初始化线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor( 1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>() ); // 创建任务 TaskCallable taskCallable = new TaskCallable(); // 向线程池提交任务,返回 future 对象 Future future = executor.submit(taskCallable); // future.get() 用于获取线程执行任务的返回值;如果线程没有执行完任务,get 方法则会阻塞,直到返回执行结果 // 打印结果: TaskFuture 任务被执行! System.out.println(future.get()); // 关闭线程池 executor.shutdown(); } } class TaskCallable implements Callable<String> { @Override public String call() { return " TaskCallable 任务被执行!"; } } 复制代码
Future 是接口,不能实例化。
Futuretask 实现了 Runnable 和 Future;能够直接实例化;并且还因为实现了 Runnable,还可以直接作为任务的载体对象提交给线程池。
使用线程池时,都可能会用到。
public class ThreadPoolExecutorTest { @Test public void testThreadPoolExecutor() throws ExecutionException, InterruptedException { // 初始化线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor( 1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>() ); // 创建任务 TaskCallable taskCallable = new TaskCallable(); // 创建用于获取返回值的 FutureTask 对象 FutureTask<String> futureTask = new FutureTask(taskCallable); // 向线程池提交任务 // 使用 FutureTask,不能同时使用 submit() 方法。如果使用 submit() 方法,返回值是 null executor.execute(futureTask); // futureTask.get() 用于获取线程执行任务的返回值,如果线程没有执行完任务,get 方法则会阻塞,直到返回执行结果 // 打印结果: TaskFuture 任务被执行! System.out.println(futureTask.get()); // 关闭线程池 executor.shutdown(); } } class TaskCallable implements Callable<String> { @Override public String call() { return " TaskCallable 任务被执行!"; } } 复制代码
见“ThreadPoolExecutor 源码解读”一文。
Executors 是并发包里的一个类,在 ThreadPoolExecutor 基础上实现了更多模式的线程池。
尽管 Executors 实现了更多模式的线程池,但是因为严重的弊端,导致实际开发中通常不被使用。例如:ThreadPoolExecutor 中 workQueue 指定队列的长度,都默认为 Integer.MAX_VALUE,无法修改。开发时,很有可能会引发任务大量堆积,从而导致内存溢出(OOM)。
有以下 6 种线程池:
以上 1、2、3 种只是通过改变 corePoolSize 、workQueue 等参数,来实现不同线程池的使用效果。
使用这种线程池,表示线程池至多只会创建 1 个线程来处理任务。
Executors 底层将 corePoolSize 设置为 1,workQueue 设置为 LinkedBolckingQueue 对象,LinkedBolckingQueue 对象默认大小为 Integer 的最大值(用以实现 workQueue 不会被装满的效果;因为在装满前,一般 JVM 就会先抛出 OOM)。
使用这种线程池,表示线程池至多只能创建 N 个线程。
Executors 底层将 corePoolSize 设置为 N,workQueue 设置为 LinkedBolckingQueue 对象,LinkedBolckingQueue 对象默认大小为 Integer 的最大值(用以实现 workQueue 不会被装满的效果;因为在装满前,一般 JVM 就会先抛出 OOM)。
使用这种线程池,表示只要用户提交任务,线程池都会保证立刻有线程执行,无需等待;反之,如果没有要执行的任务,工作线程数为 0 。(如果使用不当,很有可能导致 OOM)
Executors 底层将 corePoolSize 设置为 0,maximumPoolSize 设置为 Integer 的最大值,keepAliveTime 设置为 60 s,workQueue 设置为 SynchronousQueue 对象(SynchronousQueue 是只能装载一个元素的阻塞队列,插入一个元素后,SynchronousQueue 就会进入阻塞状态,等待元素移除后,才可以继续插入下一个元素)。
使用这种线程池,表示线程池至多只能创建 N 个线程。并且能够做到多长时间后执行一次任务,还能做到多长时间后开始执行任务,每隔多长时间再次执行。
这种线程池,就是封装了定时任务线程池,N 指定为 1。
这种线程池,是 JDK 8 开始引入的;能够根据当前服务器 CPU 创建相应个数的线程。底层不是通过 ThreadPoolExecutor 实现的。
满足以下几个特点,我们就说这个操作支持原子性,线程安全:
包含多个操作单元,但仍支持原子性,通常都是由锁实现的。
class Test { int x = 0; int y = 0; public void test() { // 原子操作 x = 10; // 大致分为两步:1)获取 x 的值到缓存里;2)取出缓存里的值,赋值给 y // 不支持原子性;获取 x 的值到缓存里之后,其它线程可能修改 x 的值,导致 y 值错误 y = x; // 大致分为三步:1)获取 x 的值到缓存里;2)取出缓存里的值加一;3)赋值给 x // 不支持原子性;原理类似 y = x; x++; } } 复制代码
Cas 是 Compare-and-swap(比较并替换)的缩写,是支持原子性的操作;在 Java 中,底层是 native 方法实现,通过 CPU 提供的 lock 信号保证的原子性。
常用的 Jedis 方法 incr()(Jedis 是 Redis 的 Java 客户端) ,可以将 Redis 中对应 key 的缓存值加 1。
想要将数据 V 的原值 O 替换为新值 N,执行 Cas 操作:
以 java.util.concurrent.atomic 包中的 AtomicInteger 为例;
public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(100); // 将 AtomicInteger 的值,从 100 替换为 200 Boolean b = atomicInteger.compareAndSet(100, 200); // 返回 true,替换成功 System.out.println(b); } 复制代码
ABA 问题。
线程1 | 线程2 |
---|---|
1. 查询数据 V,值为 A,作为预期值 | 1. 查询数据 V,值为 A,作为预期值 |
处理数据... | 2. 计算结果为 B |
处理数据... | 3. 执行 Cas 操作,将 A 替换为 B |
处理数据... | 4. 查询数据 V,值为 B,作为预期值 |
处理数据... | 5. 计算结果又为 A |
处理数据... | 6. 执行 Cas 操作,将 B 替换为 A |
2. 计算结果为 B | |
3. 执行 Cas 操作,将 A 替换为 B | |
结论:这就造成了 ABA 问题:在线程 1 操作的时候,完全感知不到线程 2 已经在线程 1 开始之后,结束之前修改过值了。 |
进行比较的时候,不再比较目标的值,而是给目标增加一个版本号的字段,每次只比较版本号;然后每次更新都升一个版本。
ReentrantLock 是 java.util.concurrent 并发包中的可重入同步锁(可重入锁就是指持有锁的线程可以重复进入有该锁的代码块);基于 AQS(AbstractQueuedSynchronized)实现。需要手动释放锁,但是支持更多功能,比如:上公平/非公平锁、可被中断定时锁、可被中断锁等。
@Test public void testReentrantLock1() { // 多个线程使用同一个 ReentrantLock 对象,上同一把锁,默认上非公平锁 Lock lockNoFair = new ReentrantLock(); try { // 本线程尝试获取锁;如果锁已经被其它线程持有,则会进入阻塞状态,直到获取到锁 lockNoFair.lock(); System.out.println("处理中..."); } finally { // 释放锁 lockNoFair.unlock(); } } @Test public void testReentrantLock2() { // 多个线程使用同一个 ReentrantLock 对象,上同一把锁,构造函数传 true,上公平锁 Lock lockFair = new ReentrantLock(true); try { // 本线程尝试获取锁;如果锁已经被其它线程持有,则会进入阻塞状态,直到获取到锁 lockFair.lock(); System.out.println("处理中..."); } finally { // 释放锁 lockFair.unlock(); } } 复制代码
AQS 是一个抽象类,是并发包中部分并发工具类的重要组成部分(大部分并发工具类的内部类 Sync,都继承了 AQS),封装了大部分核心代码。
线程获取锁的顺序完全基于调用 lock() 方法的先后顺序。
时间线 | 线程 1 | 线程 2 | 线程 3 |
---|---|---|---|
1 | 线程 1 调用 lockFair.lock() 方法,获取到锁 | ||
2 | 线程 2 调用 lockFair.lock() 方法后,没有获取到锁,将线程 2 顺序放入到链表里排队,进入阻塞状态 | ||
3 | 线程 1 调用 lockFair.unLock() 方法,释放锁,唤醒线程 2 | ||
4 | 线程 3 调用 lockFair.lock() 方法,发现线程 2 已经在链表里等待获得锁;线程 3 就追加到线程 2 之后,进行排队 | ||
5 | 线程 2 被线程 1 唤醒,重新尝试获取锁,获取锁成功 |
时间线 | 线程 1 | 线程 2 | 线程 3 |
---|---|---|---|
1 | 调用 lockNoFair.lock() 方法,获取到锁 | ||
2 | 调用 lockNoFair.lock() 方法后,没有获取到锁,线程 2 顺序追加到链表后排队,进入阻塞状态 | ||
3 | 调用 lockNoFair.unLock() 方法,释放锁,唤醒线程 2 | ||
4 | 调用 lockNoFair.lock() 方法,成功获取到锁 | ||
5 | 线程 2 被线程 1 唤醒,呆在链表里位置不动,重新尝试获取锁,获取锁失败,已经被线程 3 抢占到了锁,再次进入阻塞状态 |
lockInterruptibly() 也分为公平锁和非公平锁,与 lock() 方法的区别就在于:当线程调用 lockInterruptibly() 方法没有获取到锁,进入阻塞后;如果其它线程对该线程标记为中断状态, lockInterruptibly() 方法则会从阻塞中唤醒,抛出中断异常。
如果线程一开始就被标记为中断状态,再调用 lockInterruptibly() 方法,lockInterruptibly() 方法则会直接抛出中断异常。
tryLock(long timeout, TimeUnit unit) 也区分公平锁和非公平锁;与 lockInterruptibly() 方法的区别就在于:线程调用 tryLock(long timeout, TimeUnit unit) 方法,获取不到锁,进入阻塞后;如果在指定的时间里,仍然没有被其它释放锁的线程唤醒,则会自动唤醒,直接返回失败。
重入锁就是指持有锁的线程可以重复进入有该锁的代码区域。
在数据结构上,每一个锁都记录了当前持有锁的线程对象 exclusiveOwnerThread 和一个状态值 state;
在执行代码时,如果一个线程想要尝试获取锁时,发现 state > 0,但是 exclusiveOwnerThread 的值为当前线程对象,就会将 state 的值加一,成功重复进入有该锁限制的代码区域里。
见“ReentrantLock 源码解读“一文。
非公平锁就是在线程调用 lock() 方法,尝试获取锁的时候,会优先抢锁;抢锁失败再老实追加到链表里排队,等候上一个线程释放锁。
公平锁就是在线程调用 lock() 方法,尝试获取锁的时候,直接去链表里排队,按照调用的先后顺序获取锁。
见“ReentrantLock 源码解读“一文。
见“ReentrantLock 源码解读“一文中的白话原理。
synchronized 是 Java 语言的关键字(也就是说是 JVM 实现),是可重入锁,无需手动释放锁,代码执行结束,JVM 自动释放锁;可以修饰代码块、普通方法、静态方法。当修饰代码块和方法时。sychronized 锁定的是当前对象;如果修饰静态方法,锁定的是这个类的所有对象。如果已经有线程成功获取了锁,其它线程想要获取锁,就会进入阻塞状态。
public synchronized void test() {...} 复制代码
锁定对象的含义就是:
当一个线程进入对象的 synchronized 修饰代码块、普通方法后,其它线程不能进入此对象的其它任何 sychronized 修饰的地方,而是进入阻塞状态。
如果是换成进入对象的 sychronized 修饰静态方法后,其它线程则不能进入此类的所有对象的其它任何 sychronized 修饰的地方。
如果此对象其它方法没加 synchronized 关键字,则可以。
因为同一对象内的所有 synchronized 方法都是用当前对象作为锁,也就是说锁是同一把,所以其它线程在没有获取锁的情况下,是进入不了其它 synchronized 方法的。
不能。
JVM 实现,自动释放锁。
并发包中实现,需要手动释放锁,支持更多功能。
实际开发中,一般都采用多台服务器部署同一系统,达到负载均衡的作用, 而 sychronized 和 Reentrantlock 锁只能作用于部署在一台服务器的系统,所以现在一般都使用分布式锁(Redis 锁),而不用 sychronized 和 Reentrantlock 锁了。
悲观锁是一种锁的实现方式;表示总是很悲观,每次读/写操作的时候,都认为其它线程会影响到本次的执行结果,所以操作之前就上锁,保证数据只有自己能操作,操作完成之后再释放锁。例如:sychronized 和 Reentrantlock。
用于频发生并发写的操作。如果上锁/释放锁不会成为系统性能瓶颈,也可以使用悲观锁,因为实现方便。
实际开发中,用得较多;比如:分布式锁(Redis)。
悲观锁是锁的另一种实现方式;表示总是很乐观,每次读/写操作的时候,都认为其它线程不会影响自己的执行结果,所以不会上锁;但是如果提交事务之前发现其它线程影响到了的本次执行结果,则放弃提交。
放弃提交后,可以根据业务需要,编写直接放弃,或者自旋重试的代码逻辑。(自旋就是重复循环的意思)
用于很少发生并发写的操作。
实际开发中,用得较少。
死锁就是指永远不会被释放的锁。
死锁的发生原因一般有以下两点:
线程 A | 线程 B |
---|---|
成功获取了锁 a | 成功获取了锁 b |
尝试获取锁 b,但是因为锁 b 已经被线程 B 获取,所以进入阻塞状态 | 尝试获取锁 a,但是因为锁 a 已经被线程 A 获取,所以进入阻塞状态 |
结果:由于 A、B 两个线程互相持有对方需要的锁,双方都进入了阻塞状态,互相等待对方释放锁,导致死锁。 |
实际开发中,常会用到的锁是分布式锁(Redis 锁)和数据库自带的事务锁;如果编码不正确,可能会遇到。
如何解决,就必须具体情况,具体分析了。
从学习到面试,从面试到工作,从 coder 到 TeamLeader,每天给你答疑解惑,还能有第二份收入,这样的知识星球,难道你还要犹豫!