Java并发常见面试题总结
1、什么是线程和进程?
何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行程序,是一个进程从创建、运行到消亡的过程。
在Java中,当我们启动main函数时其实就是启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称主线程。
何为线程?
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
一个java程序的运行是main线程和多个其他线程同时运行。
2、简要描述线程与进程的关系,区别和优缺点?
一个进程中可以有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈。
-
线程是进程划分更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定。因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
-
堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象,方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
3、并行与并发的区别?
- 并行:两个及两个以上的作业在同一时刻执行 (同时)
- 并发:两个及两个以上的作业在同一时间段内执行
4、同步和异步的区别
- 同步:发出一个调用之后,在没有得到结果之前,该调用就不可以返回,一直等待
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回。
5、为什么要使用多线程?
- 从计算机底层来说:线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核CPU时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说:现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
- 单核时代:当使用多线程的时候,一个线程被IO阻塞,其他线程还可以继续使用CPU。从而提高了Java进程利用系统资源的整体效率。
- 多核时代:多线程主要为了提高进程利用多核CPU的能力。
6、使用多线程可能带来什么问题?
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄露、死锁、线程不安全等。
7、理解线程安全和不安全?
- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性
- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
8、说说线程的生命周期和状态
- NEW:初始状态,线程被创建出来但没有被调用
- RUNNABLE:运行状态,线程被调用了 start()等待运行的状态
- BLOCKED:阻塞状态,需要等待锁释放
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像WAITING那样一直等待
- TERMINATED:终止状态,表示该线程已经运行完毕
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
- 当线程执行 wait()方法之后,线程进入WAITING(等待)状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态
- TIMED_WAITING(超时等待)状态相当于在等待状态的基础上增加了超时限制,比如通过sleep(long millis)方法可以将线程置于TIMED_WAITING状态。当超时时间结束后,线程将会返回到RUNNABLE状态。
- 当线程进入synchronized 方法/块,但是锁被其他线程占有,这个时候线程就会进入BLOCKED(阻塞)状态
- 线程在执行完了run()方法之后将会进入到TERMINATED(终止)状态
9、什么是线程死锁?如何避免死锁?
认识线程死锁
- 线程死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期阻塞,因此程序不可能正常终止。
例如:线程A持有资源1,线程B持有资源2,它们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态
- 产生死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系
如何预防和避免线程死锁
预防—破坏死锁产生的必要条件即可:
- 破坏请求与保持条件:一次性申请所有的资源
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放,破坏循环等待条件。
避免死锁:
- 避免死锁就是在资源分配时,借助于算法,如(银行家算法)对资源分配进行计算评估,使其进入安全状态。
10、sleep()方法和wait()方法对比
共同点:两者都可以暂停线程的执行
区别:
- sleep()方法没有释放锁,而wait()方法释放了锁
- wait()通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
- wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或者notifyAll()方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)超时后线程会自动苏醒。
- sleep()是Thread类的静态本地方法,wait()则是Object类的本地方法。
为什么wait()方法不定义在Thread中?
wait()是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入WAITING状态,自然要操作对应的对象(Object)而非当前的线程。
类似的:为什么sleep()方法要定义在Thread?
:因为sleep()是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
11、可以直接调用Thread类的run方法吗?
new一个Thread,线程进入了新建状态。调用start()方法,会启动一个线程并使线程进入就绪状态,当分配到时间片后就可以开始运行了。start()会执行线程的相应准备工作,然后自动执行run()方法的内容,这才是真正的多线程工作。而如果直接执行Thread类的run方法,会把run()方法当成一个main线程下的普通方法去执行,并不会在某个下线程中执行它,所以这并不是多线程工作。
调用start()方法方可启动线程并使线程进入就绪状态,直接执行run()方法的话不会以多线程的方式执行。
12、volatile关键字
在java中,volatile关键字可以保证变量的可见性,如果我们将变量声明为volatile,这就指示JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。volatile关键字能保证数据的可见性,但不能保证数据的原子性。
在Java中,volatile关键字除了可以保证变量的可见性,还有一个重要的作用就是防止JVM的指令重排序。如果我们将变量声明为volatile,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式来禁止指令重排序。
13、乐观锁和悲观锁
什么是悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题,所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程。Java中 synchronized和 ReentrantLock等独占锁就是悲观锁思想的实现。高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。
什么是乐观锁
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其他线程修改了。
高并发场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致CPU飙升。
理论上来说:
- 悲观锁通常多用于写比较多的情况下(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话,也是可以考虑使用乐观锁的,要视实际情况而定
- 乐观锁通常多于写比较少的情况下(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量。
如何实现乐观锁
乐观锁一般会使用版本号机制或CAS算法实现,CAS算法相对来说更多一些。
乐观锁存在哪些问题
- ABA问题,ABA问题是乐观锁最常见的问题
- 循环时间长,开销大
- 只能保证一个共享变量的原子操作
14、synchronized关键字
synchronized是什么?有什么用?
- synchronized是Java中的一个关键字,翻译成中文时同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
- Java早期版本,synchronized属于重量级锁,效率低下。不过,在Java 6 之后,synchronized引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让synchronized锁的效率提升了很多。因此,synchronized还是可以在实际项目中使用的。
如何使用synchronized
- 修饰实例方法
给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
synchronized void method(){
//业务代码
}
- 修饰静态方法(锁当前类)
给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁
这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享
静态synchronized方法和非静态synchronized方法之间的调用互斥吗? 不互斥。如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥。因为,访问静态synchronized方法占用的锁是当前类 的锁,而访问非静态synchronized方法占用的锁是当前实例对象 的锁。
- 修饰代码块(锁指定对象/类)
synchronized(object) //表示进入同步代码库前要获得给定对象的锁
synchronized(类.class) //表示进入同步代码前要获得给定class的锁
构造方法可以用synchronized修饰吗
- 构造方法是不能使用synchronized修饰的。因为构造方法本身就属于线程安全的,不存在同步的构造方法一说。
synchronized 和 volatile 有什么区别
synchronized关键字和volatile关键字是两个互补的存在,而不是对立的存在!
- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块
- volatile关键字可以保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证
- volatile关键字主要用于解决变量在多个线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性。
15、ReentrantLock
ReentrantLock是什么?
ReentrantLock实现了Lock接口,是一个可重入且独占式的锁,和synchronized关键字类似。不过,ReentrantLock更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock里面有一个内部类Sync,Sync继承了AQS,添加锁和释放锁的大部分操作实际上都是在Sync中实现的。Sync有公平锁和非公平锁两个子类。ReentrantLock默认使用非公平锁。
公平锁和非公平锁的区别
- 公平锁:锁被释放之后,先申请的线程先得到锁,性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁
- 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
synchronized和ReentrantLock有什么区别?
- 两者都是可重入锁;
- synchronized依赖于JVM,而ReentrantLock依赖于API
- ReentrantLock比synchronized增加了一些高级功能,主要来说主要三点:
- 等待可中断
- 可实现公平锁
- 可实现选择性通知(锁可以绑定多个条件)
可中断锁和不可中断锁有什么区别
- 可中断锁:获取锁的过程可以被中断,不需要一直等到获取锁之后,才能进行其他逻辑处理。ReentrantLock就属于是可中断锁
- 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。synchronized就属于不可中断锁
16、ThreadLocal
ThreadLocal有什么用?
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决?JDK中自带的ThreadLocal类正是为了解决这样的问题。ThreadLocal类主要解决的是让每个线程绑定自己的值。
ThreadLocal内存泄露问题是怎么导致的
ThreadLocalMap中使用的key为ThreadLocal的弱引用,而value是强引用。所以,如果ThreadLocal没有被外部强引用的情况下,在垃圾回收的时候,key会被清理掉,而value不会被清理掉。
17、线程池
什么是线程池
线程池就是管理一系列线程的资源池,当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
为什么要用线程池
池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率
线程池提供了一种限制和管理资源的方式。每个线程池还维护一些基本统计信息,例如已完成任务的数量。
线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
如何创建线程池
- 方式一:(推荐)通过ThreadPoolExecutor构造函数来创建
- 方式二:通过Executor框架的工具类Executors来创建
18、Future
Future类有什么用
Future类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他的事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过Future类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
这其实就是多线程中经典的Future模式,你可以将其看做是设计模式,核心思想是异步调用,主要用在多线程领域,并非java语言独有。
在java中,Future类只是一个泛型接口,其中定义了5个方法,主要包括四个功能:
- 取消任务
- 判断任务是否被取消
- 判断任务是否已经执行完成
- 获取任务执行结果
简单理解就是:我有一个任务,提交给了Future来处理,任务执行期间我自己可以去做任何想做的事,并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以Future那里直接获取任务执行结果。