Java 并发编程知识点
1. 什么是线程和进程?区别和联系?
进程:程序的一次执行过程。一个 Java 程序的运行一般是 main 线程和多个其他线程同时运行。
线程:比进程更小的执行单位。同类的多个线程共享进程的堆和方法区,但每个线程有自己的程序计数器、虚拟机栈、本地方法栈。
程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。 为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
- 程序计数器:记录java代码下一条指令的地址,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制;在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
- 虚拟机栈:每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
2. 堆和方法区是什么?
堆和方法区是所有线程共享的资源。
堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存)。
方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
3. 如何创建线程?
使用new Thread().start()
创建线程。
在 Java 代码中使用多线程的方法:继承Thread
类、实现Runnable
接口、实现Callable
接口、使用线程池、使用CompletableFuture
类等等。
4. 线程的生命周期和状态有什么?
Java线程在生命周期中有6种状态,随着代码的执行在不同状态中切换。
- NEW:初始状态。线程被创建,但没有调用.start()。
- RUNNABLE:运行状态。线程被调用了start()开始运行。
- WAITING:等待状态。线程需要等待其他线程的特定动作,如通知或中断。
- TIME_WAITING:超时等待。线程需要等待一定的时间才能回到RUNNABLE。
- BLOCKED:阻塞状态。线程需要等待其他线程释放锁之后才能运行。
- TERMINATED:终止状态。线程运行结束
thread.join()
表示调用此方法的线程被阻塞,仅当thread完成以后,方法才能继续运行。
5. 什么是线程上下文切换?
线程切换时:线程从CPU状态中退出,需要保存当前线程的上下文(比如程序计数器、虚拟机栈、本地方法栈等),加载即将占用CPU的线程的上下文。
当前线程从CPU状态退出的几种情况:
- 主动让出CPU。比如调用sleep、wait。
- 时间片用完。操作系统防止一个线程或进程长时间占用CPU导致其他线程或进程饿死。
- 调用了阻塞类型的CPU中断。比如请求IO,线程被阻塞。
- 被终止或结束运行。
6. Thread.sleep()和Object.wait()的异同点?
同:都可以暂停线程的执行。都是native方法
不同:
- sleep()不释放锁,wait()释放锁。
- wait只能在同步代码块或同步控制块中使用,而sleep可以在任何位置使用。
- sleep()常被用于暂停执行,wait()常被用于线程通信。
- 唤醒方法不同。sleep()和wait(long timeout)完成后,线程自动苏醒;而wait()调用后,需要别的线程调用同一个对象上的notify()或notifyAll()方法来唤醒正在等待的线程。
- sleep()时Thread类的静态本地方法,wait()是Object类的本地方法。
wait()为什么是定义在Object中而不是Thread中?
每个对象都有对象锁,wait()是希望让获得对象锁的线程等待,会释放当前线程占有的对象锁,是需要操作对应的Object而不是Thread。
sleep()为什么定义在Thread中?
sleep()是希望当前线程暂停执行,不涉及对象。
7. 直接调用Thread类的run方法会怎么样?
线程从创建到运行,是在new之后,执行thread.start()方法之后进入RUNNABLE状态。start()方法进行线程的准备工作,之后自动执行run()方法,这是多线程运行模式。但是如果手动调用thread.run()而不执行start()的话只会把run()方法当作普通方法,而不会以多线程方式执行。
8. 并发与并行、同步和异步?
并发:多个作业在同一时间段执行。并行:多个作业在同一时刻执行。
同步:发出调用后,没有得到结果前,此调用不能返回。异步:发出调用后不用等待返回结果,调用可以直接返回。
9. 为什么需要使用多线程?
减少开销:线程可以比作轻量级的进程,线程是程序执行的最小单位,线程切换和调度成本远低于进程,多核CPU时代多个线程可以同时运行,减少线程上下文开销。
互联网发展的要求:如今的系统很多需要百万级、千万级的并发,多线程并发编程时开发高并发系统的基础,利用多线程机制可以提升系统并发能力和性能。
从计算机底层来讲:
单核时代:多线程可以提高进程利用CPU和IO系统的效率。如果Java进程只有一个线程,当线程被IO阻塞时整个进程都被阻塞,CPU和IO设置只能有一个在运行,系统整体效率低。
多核时代:多线程可以提高进程利用多核CPU的能力。多个线程可以被映射到底层多个CPU核心上执行,提高效率。
10. 单核CPU支持Java多线程吗?
支持。操作系统通过时间片轮转的方式,把CPU时间分配给不同线程,虽然单核CPU一次只能执行一个线程,但是通过多个线程间快速切换,可以让用户感觉多个任务同时进行。
11. 单核CPU上运行多个线程效率一定高吗?
不一定,取决于线程的类型和任务性质,而且都要适度。
对于CPU密集型线程,进行计算和逻辑处理需要占用大量的CPU资源,多个线程运行时会导致频繁的线程切换,增加系统开销。
对于IO密集型线程,主要进行输入输出操作,多个线程同时运行可以利用CPU在等待IO时的空闲时间,提高了效率。
12. 并发编程可能带来的问题?什么是线程安全和不安全?
并发编程目的是提高程序执行效率,但可能会导致内存泄漏、死锁、线程不安全。
线程安全:多线程环境下,同一份数据,不管多少线程同时访问,都能够保证数据安全和一致性。
线程不安全:多线程环境下,同一份数据,多个线程同时访问,数据不一致、错误、丢失等。
13. 什么是线程死锁?Java如何检测死锁?
死锁形成的4个条件:
- 互斥条件。一个资源任意时刻只能被一个线程占用。
- 请求与保持条件。一个线程请求新的资源阻塞时,对以获取的资源保持不放。
- 不可剥夺条件。线程已获取的资源在没有使用完之前不会被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件。多个线程之间形成首尾相接的循环等待资源关系。
Java如何检测死锁?
- 使用jmap、jstack等查看JVM线程栈和堆内存的情况,死锁发生时jstack输出中通常包括"Found one Java-level deadlock:"字样,后面跟随死锁相关的线程信息。
shell # jps(Java Virtual Machine Process Status Tool)是Java自带的一个工具,它可以列出当前运行的所有Java进程及其相关信息。 jps -l # 找到当前java进程的pid jstack <pid>
- 实际项目中可以搭配top、df、free等命令查看操作系统信息,出现死锁可能导致CPU、内存资源消耗过高。
- 采用VisualVM、JConsole等工具排查。
14. 如何预防和避免死锁?
预防死锁:破坏死锁产生的必要条件。
- 破坏请求保持条件:一次性申请所有资源。
- 破坏不可剥夺条件:如何线程申请资源申请不到,就释放持有的资源。
- 破坏循环等待条件:申请资源时按照一定的线程顺序,释放资源时按照逆序。
避免死锁:资源分配时,借助算法(如银行家算法)对资源分配进行评估,使其进入安全状态。
银行家算法:
思想:当一个线程申请资源时,系统先试探性地分配,判断分配后系统是否处于安全状态,如果安全那么就分配,否则不分配让线程继续等待。
如何判断安全状态?假设线程P1申请资源R1,系统试探性分配后,判断剩余的资源中,能否满足队列中的其他某个线程执行完毕:
- 如果能,那么继续试探性的分配,直到所有的线程都能执行完毕,此时处于安全状态,给线程分配资源的顺序即为安全序列。
- 如果不能,则系统处于不安全状态。此时没有一个进程能够完成并释放资源,随着时间推移,系统会处于死锁状态。
15. 什么是乐观锁?问题?使用场景?
乐观锁:假设最好的情况,对共享资源的访问都不会产生问题,不用加锁不用等待,只需要在提交修改时验证(版本号机制、CAS算法)资源是否被其他线程修改了。例如AtomicInteger、LongAdder
可能产生的问题:高写入场景下,乐观锁可能导致 验证时 频繁的失败和重试,影响性能。(LongAdder
以空间换时间的方式解决了大量失败重试的问题)
适用场景:优点是不存在锁竞争导致的现场阻塞,不会有死锁问题,性能更好,适用于多读场景。但是乐观锁主要针对对象是单个共享变量。
16. 什么是悲观锁?问题?使用场景?
悲观锁:假设最坏的情况,对共享资源的每次访问都可能发生问题,因此每次请求就上锁,一个资源一次只能被一个线程使用,其他线程阻塞。例如synchronized和ReentrantLock
可能带来的问题:
- 高并发场景下,激烈的锁竞争造成线程阻塞,大量阻塞时操作系统上下文切换会影响系统性能。
- 可能发生死锁问题,影响代码正常运行。
使用场景:多写场景,竞争激烈时,可以采用悲观锁,从而避免乐观锁频繁失败和重试影响性能。但如果乐观锁用LongAdder解决了频繁失败和重试问题的话,也可以用乐观锁。
17. 乐观锁如何实现的?什么是CAS算法?
版本号法:数据表中添加版本号,每次修改都让版本号加1,读取数据时读取版本号,写入时比较版本号和目前数据库中的数据版本号是否一致,一致则更新,不一致则说明其他线程已经修改了,重试操作。
CAS算法:比较和交换算法。CAS 操作是比较并交换,先比较内存中的值与预期值是否相等,相等则更新为新值。不相等说明有其他线程修改了变量值,需要放弃或重试。
18. java中是如何实现CAS算法的?有什么问题?
- CAS 操作是比较并交换,先比较内存中的值与预期值是否相等,相等则更新为新值。CAS是一个无锁的原子算法。多个线程同时使用CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。
- Java中CAS的实现依赖于Unsafe类。
sun.misc
包下的Unsafe
类提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现的对Object
、int
、long
类型的 CAS 操作。 Unsafe
类中的CAS方法是native方法,是通过本地C、C++代码实现而不是java实现的,这些方法直接调用底层硬件指令实现原子操作,也就是说Java中CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI调用 Java Native Interface)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。- 可能的问题?
- ABA问题:如果变量V的值最初为A,被改为B后再改回来变成A,CAS算法可能误认为变量没改变。解决思路是在变量前面追加上版本号或者时间戳。
- 循环时间长开销大:CAS 算法在执行失败时,会一直循环尝试,直到成功为止。如果 CAS 操作一直失败,那么循环时间就会很长,从而导致开销增大。可能会导致线程饥饿:如果一个线程一直占用着 CAS 操作,那么其他线程就可能会一直等待,从而导致线程饥饿。
- 只能保证一个共享变量的原子操作:CAS 算法只能保证一个共享变量的原子操作,如果需要对多个共享变量进行原子操作,那么就需要加锁或者把多个变量封装在一个
AtomicReference
类(保证引用对象之间的原子性)中。
Unsafe
类位于sun.misc
包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。
19. 什么是自旋锁?
什么是自旋锁机制?当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。再比如CSA算法因为并发冲突失败时,会不断循环尝试直到成功。
20. volatile关键字的作用和特点?
作用:
- volatile关键字用于在并发编程中保证变量可见性,被volatile修饰的变量是共享的、不稳定的,每次都在主存中读取。
- volatile关键字可以防止JVM指令重排序。
volatile
关键字不能保证对变量的操作是原子性的。- 原子性还得看Synchronized、Lock、原子变量
AtomicInteger
- 双重检验锁方式实现单例模式(线程安全)由于 JVM 具有指令重排的特性,uniqueInstance = new Singleton()执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用
getUniqueInstance
() 后发现uniqueInstance
不为空,因此返回uniqueInstance
,但此时uniqueInstance
还未被初始化。
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
//只有getInstance是public的
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
//1. 为 uniqueInstance 分配内存空间
//2. 初始化 uniqueInstance
//3. 将 uniqueInstance 指向分配的内存地址
}
}
}
return uniqueInstance;
}
}
- 比如对
public volatile static int inc = 0;
的inc操作inc++
分为①从读取inc的值,inc值加1,inc值写回内存。volatile无法保证这三个操作具有原子性。
改进办法:对操作函数使用synchronized
加锁、使用AtomicInteger对象、使用ReentrantLock[riːˈentrənt lɒk]类加锁。
21. synchronized 和 volatile 有什么区别?
互补而非对立。
解决的问题:volatile主要用于解决变量在多个线程间的可见性,synchronized解决多个线程访问资源的同步性。
特性:synchronized可以保证可见性、有序性、原子性,volatile只能实现可见性和有序性。
性能:volatile是synchronized轻量级实现,比synchronized性能好。
修饰对象:volatile只能用于变量,synchronized还可修饰方法和代码块。
22. synchronized关键字
在 Java 中,synchronized 关键字用于实现同步机制,它可以保证在同一时刻只有一个线程可以访问被 synchronized 修饰的代码块或方法。
23. 如何使用 synchronized?
synchronized
关键字的使用方式主要有下面 3 种:
- 修饰实例方法 -> 给对象实例上锁
- 修饰静态方法 -> 给类上锁
- 修饰代码块
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类名.class)
表示进入同步代码前要获得 给定 Class 的锁
静态synchronized
方法和非静态synchronized
方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态synchronized
方法,是允许的,不会发生互斥现象,因为访问静态synchronized
方法占用的锁是当前类的锁,而访问非静态synchronized
方法占用的锁是当前实例对象锁。
24. synchronized能否修饰构造函数?
不能。构造方法本身是线程安全的,如果在构造方法中涉及到共享资源的操作,需要采取同步措施,比如可以用synchronized代码块。
25. synchronized关键字的特性是什么?
synchronized
关键字可以保证并发编程的三大特性:原子性、可见性、有序性,而volatile
关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的synchronized
。
原子性:一个或多个操作全部执行成功或者全部执行失败。synchronized
关键字可以保证只有一个线程拿到锁,访问共享资源。
可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。
有序性:程序的执行顺序会按照代码的先后顺序执行。
26. synchronized关键字可以实现什么类型的锁?
悲观锁:synchronized
关键字实现的是悲观锁,每次访问共享资源时都会上锁。
非公平锁:synchronized
关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
可重入锁:synchronized
关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
独占锁或者排他锁:synchronized
关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。
27. synchronized的底层原理是什么?
synchronized
关键字的底层原理主要依赖监视器锁(Monitor),通过进入和退出Monitor对象。
在Java虚拟机HotSpot
中,monitor是由ObjectMonitor
实现的,其源码是用C++语言编写的。ObjectMonitor
数据结构中,包括锁计数器_count
、等待线程数_waiters
、锁重入次数_recursions
、Monitor对象_object
、持有ObjectMonitor
对象的线程_owner
、wait状态的线程列表_WaitSet
、等待锁状态的线程列表_EntryList
synchronized 修饰代码块
当用 synchronized 修饰代码块时,编译后的字节码会有 monitorenter
和monitorexit
指令,分别对应的是获得锁和解锁。enter和exit两个指令保证了代码是否顺利都能释放锁。
synchronized 修饰方法
当用 synchronized 修饰方法时,会给方法加上ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM在进入方法时会进行锁竞争。
竞争Monitor的实现细节
- 当多个线程同时访问一段同步代码时,首先会进入
_EntryList
队列。 - (通过CAS的方法)尝试获取Monitor对象,锁的计数器为0表示可以获取到。获取到之后把
_owner
字段设置为当前线程,进入临界区,Monitor对象的锁计数器加1,_recursions
加1 。 - 若持有Monitor的线程调用
wait()
方法,将释放当前持有的Monitor,_owner
变量恢复为null,_count
减1,_recursions
减1,同时该线程进入_WaitSet
集合中等待被唤醒。在_WaitSet
集合中的线程被唤醒后,再次放到_EntryList
队列中,重新竞争获取锁。 - 执行完同步代码块,释放锁,
_count
减1,_recursions
减1,如果_recursions
减到 0,就说明线程需要释放锁了。释放Monitor并复位变量的值,以便其他线程进入获取锁。
可重入锁是根据_recursions
来判断的,重入一次就执行_recursions++
,解锁一次就执行_recursions--
,如果 _recursions
减到 0 ,就说明需要释放锁了