Java面试总结第二篇~ 主要是线程与线程池的相关知识点
Java多线程和并发
1. 进程与线程
1. 进程和线程的区别
-
进程和线程的由来
- 串行
- 批处理
- 进程:独占内存空间,保存各自运行状态,相互间不干扰且可以互相切换,为并发处理任务提供了可能
- 线程:共享进程的内存资源,相互间切换更快速,支持更细粒度的任务控制,使进程内的子任务得以并发执行
-
进程是资源分配的最小单位,线程是CPU调度的最小单位
-
所有与进程相关的资源,都被记录在PCB中
-
进程是抢占处理机的调度单位;线程属于某个进程,共享其资源
-
线程只由堆栈寄存器、程序计数器和TCB组成
-
-
总结
- 线程不能看做独立应用,而进程可看做独立应用
- 进程有独立的地址空间,相互不影响,线程只是进程的不同执行路径
- 线程没有独立的地址空间,多进程的程序比多线程程序要更健壮
- 进程的切换比线程的切换开销大
2. Java进程和线程的关系
- Java对操作系统提供的功能进行封装,包括进程和线程
- 运行一个程序会产生一个进程,进程包含至少一个线程
- 每个进程对应一个JVM实例,多个线程共享JVM里的堆
- Java采用单线程编程模型,程序会主动创建主线程
- 主线程可以创建子线程,原则上要后于子线程完成执行
3. Thread中的start()和run()方法的区别
-
start()方法底层是c++代码:调用了jvm中的JVM_StartThread方法,创建了一个新线程
-
调用start()方法会创建一个新的子线程并启动
-
run()方法只是Thread的一个普通方法的调用
4. Thread和Runnable有什么关系
- Thread是实现了Runnable接口的类,使得run支持多线程
- 因类的单一继承原则,推荐多使用Runnable接口
5. 如何给run()方法传参
-
实现的方式主要有三种
- 构造函数传参
- 成员变量传参
- 回调函数传参
-
如何处理线程的返回值:三种
-
主线程等待法(Thread.sleep())外面循环等待
-
使用Thread类的join()阻塞当前线程以等待子线程处理完毕:用join()代替循环等待
-
通过Callable接口实现:通过FutureTask Or 线程池获取
// 首先实现Callable接口 重写call方法 public class MyCallable implements Callable<String> { @Override public String call() throws Exception{ String value="test"; System.out.println("Ready to work"); Thread.currentThread().sleep(5000); System.out.println("task done"); return value; } }
在FutureTask中实现该方法
// 新建一个FutureTask 用isDone方法获取是否已经执行完毕的状态 public class FutureTaskDemo { public static void main(String[] args) throws ExecutionException, InterruptedException { FutureTask<String> task = new FutureTask<String>(new MyCallable()); new Thread(task).start(); if(!task.isDone()){ System.out.println("task has not finished, please wait!"); } System.out.println("task return: " + task.get()); } }
通过线程池
public class ThreadPoolDemo { public static void main(String[] args) { ExecutorService newCachedThreadPool = Executors.newCachedThreadPool(); Future<String> future = newCachedThreadPool.submit(new MyCallable()); if(!future.isDone()){ System.out.println("task has not finished, please wait!"); } try { System.out.println(future.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } finally { newCachedThreadPool.shutdown(); } } }
-
6. 线程的状态
-
枚举类型中有六个状态
-
新建New:创建后尚未启动的线程的状态
-
运行Runnable:包含Running和Ready
-
无限期等待 Waiting:不会被分配CPU执行时间,需要显式被唤醒
没有设置Timeout参数的Object.wait()方法 没有设置TImeout参数的Thread.join()方法 LockSupport.park()方法
-
限期等待Timed Waiting:在一定时间后会由系统自动唤醒
Thread.sleep()方法 设置了Timeout参数的Object.wait()方法 设置了Tiimeout参数的Thread.join()方法 LockSupport.parkNanos()方法 LockSupport.parkUntil()方法
-
阻塞 Blocked:等待获取排它锁
-
结束Terminated:已终止线程的状态,线程已经结束执行(结束了在调用会报错)
-
7. sleep和wait的区别
- sleep()是Thread类中的方法 wait是Object类中定义的方法
- sleep()可以在任何地方使用
- wait()方法只能在synchronized方法或synchronized块中使用
- 最主要的区别:
- Thread.sleep只会让出cpu,不会导致锁行为的改变
- Object.wait不仅会让出cpu,还会释放已经占有的同步资源锁
8. notify与notifyAll的区别
- 两个概念
- 锁池 EntryList:其他线程等待锁的释放的地方
- 等待池 WaitSet:调用了wait()中,就进入了等待池
- 区别:
- notifyAll会让所有处于等待池的线程全部进入锁池去竞争获取锁的机会
- notify只会随机选取一个处于等到吃的线程进入锁池去竞争锁的机会
9. yield
- 表示当前线程愿意让出CPU的使用权,但是线程调度器可能会忽略这个暗示
- 取决于调度器
10. 中断线程
- 通过调用stop()方法停止线程:已抛弃
- suspend()、 resume()方法也都被废弃
- 目前:调用interrupt(),通知线程应该中断了
- 如果处于被阻塞状态,那么退出阻塞状态,抛出
InterruptedException
异常 - 如果正常活动状态,那么会将该线程的中断标志设置为true,该线程将会继续执行
- 检查中断标记
- 如果处于被阻塞状态,那么退出阻塞状态,抛出
11. 线程状态
2. 锁相关
1. Synchronized
- 互斥锁:同一时间只有一个线程持有
- 可见性:修改操作对其他线程是可见的
- 锁的不是代码,是对象
- 根据锁的分类:获取对象锁和获取类锁
- 同步代码块(synchronized(this) synchronized(类实例对象))锁的是括号中的实例对象
- 同步非静态方法(synchronized method)
- 获取类锁的两种用法
- 同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)
- 同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)
- 对象锁和类锁的总结
- 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块
- 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞
- 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞
- 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然
- 同一个类的不同对象的对象锁互不干扰
- 类所由于也是一种特殊的对象锁,因此和上述1,2,3,4一直,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的
- 类所和对象锁互不干扰
2. Synchronized底层原理
1. 对象在内存中的布局
- 对象头
- 实例数据
- 对齐填充
2. 对象头的结构
3. Mark Word的结构
4. Monitor
-
每个Java对象天生自带了一把看不见的锁
-
Monitor锁的竞争、释放与获取
5. Synchronized发展
-
什么是重入:一个线程再次请求自己持有对象锁的临界资源,这种情况属于重入
- Synchronized是可重入的
-
早期版本中,Synchronized属于重量级锁,依赖于Mutex Lock实现
-
线程之间的切换要从用户态转到核心态,开销较大
-
Java6之后,Synchronized性能得到了很大的提升
- Adaptive Spinning
- Lightweight Locking
- Lock Eliminate
- Biased Locking
- Lock Coarsening
6. 自旋锁与自适应自旋锁(锁优化)
- 自旋锁
- 一些共享数据的锁定状态持续时间较短,切换线程不值得
- 通过让线程执行忙循环等待锁的释放,不让出CPU
- 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销(PreBlockSpin)
- 自适应自旋锁
- 自选的次数不再固定
- 由前一次在同一个锁上的自选时间及锁的拥有者的状态来决定
- 锁消除
- JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁
- 锁粗化
- 将锁的范围加大,避免反复加锁和解锁
7. Synchronized的四种状态
无锁、偏向锁、轻量级锁、重量级锁
- 膨胀方向:无锁->偏向锁->轻量级锁->重量级锁
-
偏向锁
- 锁不存在多线程竞争,总是由容易线程多次获得
- 一个线程获得一个锁之后,进入偏向模式,此时Mark Word的结构变成了变相所结构
- 再次请求锁时,无需再做任何同步操作,获取过程只要检查Mark Word的锁标记位为偏向锁以及当前线程Id等于MarkWord中的ThreadID即可 ,省去了大量有关锁申请的操作
- 不适用于锁竞争比较激烈的场景
- 锁不存在多线程竞争,总是由容易线程多次获得
-
轻量级锁
- 由偏向锁升级而来,偏向锁运行在一个线程进入同步快的情况下,当第二个线程加入锁征用的时候,偏向锁就会升级成为轻量级锁
- 适用的场景:线程交替执行同步块
- 同一时间访问同一锁,就会导致轻量级锁膨胀为重量级锁
-
锁的内存语义
- 当线程刷新时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中
- 而当线程获取锁时,Java内存模型会吧该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
8. 偏向锁、轻量级锁、重量级锁的汇总
3. Synchronized和ReentrantLock的区别
1. ReentrantLock(再入锁)
- 位于Java.util.concurrent.locks包
- 和CountDownLatch、FutureTask、Semaphore一样基于AQS AbstractQueuedSynchronizer实现
- 能够实现比Synchronized更细粒度的控制,比如fairness
- 调用lock()之后,必须调用unlock()释放锁
- 性能未必比Synchronized高,并且也是可重入的
2. 公平性的设置
-
公平性
ReentrantLock fairLock = new ReentrantLock(true);
-
参数为true时,倾向于将锁赋予等待时间最久的线程
-
公平锁:获取锁的顺序按照先后调用lock方法的顺序(慎用)
-
非公平锁:抢占的顺序不一定,看运气
-
Synchronized是非公平锁
-
ReentrantLock将锁对象化
- 判断是否有线程,或者某个特定线程,在排队等待获取锁
- 带超时的获取锁的尝试
- 感知有没有成功的获取锁
-
是否能将wait/notify/notifyAll对象化?
- JUC包下的locks.Condition
3. 区别
- Synchronized是关键字
- ReentrantLock是类,比Synchronized更灵活
- ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
- ReentrantLock可以获取各种锁的信息
- 可以灵活的实现多路通知
- 机制不同:sync操作的是MarkWord,lock调用Unsafe类的park()方法
4. JMM模型
-
Java内存模型:一组规范
-
JMM中的主内存
- 存储Java实例对象
- 包括成员变量、类信息、常量、静态变量等
- 属于数据共享的区域,多线程并发操作时会引发线程安全问题
-
JMM中的工作内存
- 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
- 字节码行号指示器、Native方法信息
- 属于线程私有数据区域,不存在线程安全问题
-
JMM与Java内存区域
- 概念不同
- JMM描述的是一组规则,围绕原子性、有序性、可见性展开
- 相似点:存在共享区域和私有区域
-
主内存和工作内存
- 方法里的基本数据类型,本地变量将直接存储在工作内存的栈帧结构中
- 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
- 成员变量、static变量、类信息均会被存储在主内存中
- 主内存共享的方式是线程各拷贝一份数据到工作内存,操作完成之后刷新回主内存
-
指令重排序需要满足的条件
- 在单线程环境下不能改变程序运行的结果
- 存在数据以来关系的不允许重排序
无法通过happens-before原则推导出来的,才能进行指令的重排序
-
A操作的结果需要对B操作课件,则A与B存在happens-before关系
-
happens-before八大原则
5. Volatile
- JVM提供的轻量级同步机制
- 保证被volatile修饰的共享变量对所有线程总是可见的
- 禁止指令的重排序优化
- volatile的可见性:原子操作
- volatile变量为何立即可见
- 当写一个volatile变量时,JMM会吧该线程对应的工作内存中的共享变量值刷新到主内存中
- 当读取一个volatile变量时,JMM会吧该线程对应的工作内存置为无效
- volatile如何禁止重排优化
- 内存屏障(Memory Barrier)
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性
- 通过从插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化
- 强制刷出CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本
- 内存屏障(Memory Barrier)
6. Volatile和synchronized的区别
7. CAS(Compare and Swap)
- 支持原子更新操作,适用于计数器,序列发生器等场景
- 属于乐观锁机制
- CAS操作失败时由开发者决定是继续尝试,还是执行别的操作
- CAS思想
- 三个操作数 内存位置 V 预期原值 A和新值 B
- 缺点
- 若循环时间长,则开销很大
- 只能保证一个共享变量的原子操作
- ABA问题:AtomicStampedReference:控制版本
8. Java线程池
-
利用Executors创建不同的线程池满足不同的场景
-
Fork/Join框架
- 大任务分割成若干个小任务并执行,汇总小任务结果
- Work-Stealing算法:从其他队列里窃取任务来执行:双端队列
-
为什么要用
- 降低资源消耗
- 提高线程的可管理性
-
ThreadPoolExecutor
-
构造函数
coolPoolSize: 核心线程的最大个数 maximumPoolSize:线程不够用时能够创建的最大线程数 workQUeue:任务等待队列(排队机制不同) keepAliceTime:抢占的顺序不一定,看运气 threadFactory:创建新线程,Executors.defaultThreadFactory() handler:线程池的饱和策略 1. AbortPolicy:直接抛出异常,默认 2. discardPolicy:直接丢弃任务 3. CallerRunnsPolicy:用调用者所在的线程来执行任务 4. DiscardOldestPolicy:丢弃最靠前的任务,并执行当前任务
-
-
新任务提交execute执行后的判断
-
线程池的状态
- Running:能接受新提交的任务,并且也能处理阻塞队列中的任务
- ShutDown:不能在接受新提交的任务,但可以处理存量任务
- Stop(Shutdown now):不在接受新提交的任务,也不处理存量任务
- Tidying:所有任务都已终止
- terminated:terminated()方法执行完后进入该状态
-
线程池的大小如何设定
- CPU密集型:线程数= CPU核数 + 1
- I/O密集型:线程数= CPU核数 * (1 + 平均等待时间/ 平均工作时间)