Java多线程问题
总结Java面试中常见的多线程问题
文章目录
- Java多线程问题
- 1、简述java内存模型
- 2、简述as-if-serial和heppens-before
- 3、线程
- 4、Java指令重排序
- 5、有序性
- 6、线程、进程的区别
- 7、程序计数器为什么私有
- 8、虚拟机栈和本地方法栈为什么私有
- 9、并发和并行的区别
- 10、进程间的通信
- 11、线程之间的通信
- 12、IO模型
- 13、进程的状态
- 14、上下文切换
- 15、进程的中断
- 16、CAS技术
- 17、CAS技术存在的问题
- 18、Scoket
- 19、线程池
- 20、为什么调用start()方法时会执行run()方法,为什么不直接调用run()方法?
- 21、死锁
- 22、volatile
- 23、ThreadLocal
- 24、可重入锁
- 25、unsafe类
- 26、伪共享
- 27、CountDownLatch和Semaphore的区别和底层原理
1、简述java内存模型
java内存模型定义了程序中各种变量的访问规则。其规定所有变量都存储在主内存,线程均有自己的工作内存。工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。操作完成后,线程的工作内存通过缓存一致性协议将操作完的数据刷回主存
2、简述as-if-serial和heppens-before
(1)as-if-serial
编译器等会对原始的程序进行指令重排序和优化。但不管怎么重排序,其结果和用户原始程序输出预定结果一致。
(2)heppens-before八大原则
- 程序次序规则:一个线程内写在前面的操作先行发生于后面的。
- 锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。
- volatile 规则:对 volatile 变量的写操作先行发生于后面的读操作。
- 线程启动规则:线程的 start 方法先行发生于线程的每个动作。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 线程终止规则:线程中所有操作先行发生于对线程的终止检测。
- 对象终结规则:对象的初始化先行发生于 finalize 方法。
- 传递性规则:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作C
(3) 两者的区别
as-if-serial 保证单线程程序的执行结果不变,happens-before 保证正确同步的多线程程序的执行结果不变。
3、线程
(1)什么是线程同步?什么是线程互斥
- 线程互斥指某一个资源只能被一个访问者访问,具有唯一性和排他性。但访问者对资源的访问的顺序是乱序的。
- 线程的同步是指在互斥的基础上使得访问者对资源进行有序访问
(2)Java程序中如何保证线程的安全运行
- 原子性:一个或者多个操作在CPU执行过程中不被终端的特性。线程切换带来原子性问题。可用Atomic开头的原子类,synchronized,lock来解决。
- 可见性:一个线程对共享变量的修改,另外一个线程能够立即看到。缓存导致的可见性问题。volatile,synchronized,final都能保证可见性。
- 有序性:程序执行的顺序按照代码的先后顺序执行。编译优化带来的有序性问题。可用happens-before规则解决。
(3)三个线程轮流打印ABC,打印n次
class Wait_Notify_ACB {
private int num;
private static final Object LOCK = new Object();
private void printABC(String name, int targetNum) {
for (int i = 0; i < 10; i++) {
synchronized (LOCK) {
while (num % 3 != targetNum) { //循环判断,避免程序虚假唤醒
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.print(name);
LOCK.notifyAll();
}
}
}
public static void main(String[] args) {
Wait_Notify_ACB wait_notify_acb = new Wait_Notify_ACB ();
new Thread(() -> {
wait_notify_acb.printABC("A", 0);
}, "A").start();
new Thread(() -> {
wait_notify_acb.printABC("B", 1);
}, "B").start();
new Thread(() -> {
wait_notify_acb.printABC("C", 2);
}, "C").start();
}
}
class Join_ABC {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread t1 = new Thread(new printABC(null),"A");
Thread t2 = new Thread(new printABC(t1),"B");
Thread t3 = new Thread(new printABC(t2),"C");
t0.start();
t1.start();
t2.start();
Thread.sleep(10); //这里是要保证只有t1、t2、t3为一组,进行执行才能保证t1->t2->t3的执行顺序。
}
}
static class printABC implements Runnable{
private Thread beforeThread;
public printABC(Thread beforeThread) {
this.beforeThread = beforeThread;
}
@Override
public void run() {
if(beforeThread!=null) {
try {
beforeThread.join();
System.out.print(Thread.currentThread().getName());
}catch(Exception e){
e.printStackTrace();
}
}else {
System.out.print(Thread.currentThread().getName());
}
}
}
}
4、Java指令重排序
Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序执行的结果一致,但是在多线程下就会存在问题。
5、有序性
即虽然多线程存在并发和指令优化等操作,在本线程内观察该线程的所有执行操作是有序的。
6、线程、进程的区别
- 进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,进程是系统进行资源分配的独立实体,每个进程有独立的地址空间。
- 线程是进程的一个实体,是进程的一条执行路径。线程是CPU独立运行和调度的基本单位。
区别:进程之间不能共享资源,而线程共享所在进程的地址空间和其他资源,同时线程还有自己的栈和栈指针、程序计数器等寄存器。进程有自己独立的地址空间,线程没有,线程依赖于进程存在。
(1)线程的三种创建方式
- 继承Thread类,重写run方法。如果继承了Thread类,就不能继承其他的类了。任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码。
public class TestThread extends Thread{
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+" "+ i);
}
}
public static void main(String[] args) {
new TestThread().start();
new TestThread().start();
}
}
- 实现Runnable接口,重写run方法。线程公用一个代码逻辑,可以添加参数进行任务区分。但是都没有返回值。
public class TestRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+" "+ i);
}
}
public static void main(String[] args) {
TestRunnable runnable = new TestRunnable();
new Thread(runnable).start();
new Thread(runnable).start();
}
}
- FutrueTask对象创建,可以获取返回值。实现Callable接口的call方法。
public class CallerTask implements Callable<String> {
//重写call方法
@Override
public String call() throws Exception {
return "hello";
}
public static void main(String[] args) throws InterruptedException{
//创建异步任务
FutureTask<String> futureTask = new FutureTask<>(new CallerTask());
//启动线程
new Thread(futureTask).start();
try {
//等待线程执行完毕,返回结果
String result = futureTask.get();
System.out.println(result);
}
catch (ExecutionException e){
e.printStackTrace();
}
}
}
(2) 守护线程与用户线程
java中的线程分为守护线程和用户线程两类,比如main函数所在的线程就是一个用户线程,JVM内部还启动了许多守护线程,如垃圾回收线程。守护线程是否结束并不影响JVM退出,只要有一个用户线程还没结束,JVM就不会退出。
任何线程都可以设置为守护线程和用户线程,通过方法Thread.setDaemon(bool on)设置,true则是将该线程设置为守护线程,false则是将该线程设置为用户线程。该方法必须在thread.start之前调用。
(3)java线程的状态
- Runnable:运行状态,表示正在JVM执行,但不一定真的在跑,有可能在排队等待cpu
- blocked:阻塞状态。线程等待获取锁。
- waiting:等待状态。运行wait()/join()进入该状态。
- timed_waiting:期限等待。在一定时间后跳出该状态。
- terminated:结束状态。
(4)线程和进程的应用场景
- 需要频繁创建和销毁优先用线程
- 需要大量计算的优先使用线程(CPU频繁切换)
- 强相关的处理用线程,弱相关的处理使用进程
- 消息收发、消息处理属于弱相关任务,而消息处理里的消息解码、业务处理就是强相关任务
- 可能要扩展到多机分布的用进程,多核分布的用线程
7、程序计数器为什么私有
程序计数器是一块较小的内存空间,它可以看作是当前程序所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
8、虚拟机栈和本地方法栈为什么私有
为了保证线程中的局部变量不被其他线程访问到,都是线程私有的。
9、并发和并行的区别
- 并行是指两个或者多个事件在同一时刻发生;并发是指两个或者多个事件在同一时间间隔发生。
- 并行是在不同实体上的多个事件;并发是在同一实体上的多个事件
10、进程间的通信
-
内存共享:是最快的进程间的通讯方式。通常由一个进程创建,其余进程对这块内存区进行读写。共享内存直接在进程的虚拟地址空间进行操作,不再通过执行进入内核的系统调用来传递彼此的数据
-
消息队列:消息队列就是消息的一个链表,它允许一个或多个进程向它写消息,一个或多个进程从中读消息。
-
信号量:在进程访问临界资源之前,需要测试信号量,如果为正数,则信号量-1并且进程可以进入临界区,若为非正数,则进程挂起放入等待队列,直至有进程退出临界区,释放资源并+1信号量,此时唤醒等待队列的进程。信号量本身就是临界资源,所以必须是原子操作。
-
管道:管道是由内核管理的一个缓冲区(buffer),相当于我们放入内存中的一个纸条。管道的一端连接一个进程的输出。这个进程会向管道中放入信息。管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。一个缓冲区不需要很大,它被设计成为环形的数据结构,以便管道可以被循环利用。当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。
-
套接字
-
内存映射:内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的进程地址空间来实现它
-
共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥
11、线程之间的通信
- 全局变量:进程内线程间内存共享,定义全局变量时使用volatile定义,避免编译器对变量进行优化。
- synchronized关键字:确保多个线程在同一时刻只能有一个处于方法或同步块中。
- wait/notify方法
- 信号量
- 管道
文件描述符(File Description)
是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。进程获取描述符最常见的方法是通过本机 子例程 open或create获取或者通过父进程继承。对于每个进程,操作系统内核在u_block结构中维护文件描述符表,所有的文件描述符都在该表中建立索引。
好处:
- 基于文件描述符的IO操作兼容POSIX标准
- 在UNIX、Linux的系统调用中,大量的系统调用都是依赖于文件描述符
12、IO模型
(1)同步阻塞
同步阻塞IO模型是最简单的IO模型,用户线程在内核进行IO操作时被阻塞
(2)同步非阻塞IO
在同步阻塞的基础上,将socket设置为NONBLOOK。用户线程发起IO请求后立即返回,但并未读取到任何数据,用户线程需要不断的发起IO请求,直到数据到达后,才真正读取到数据,继续执行
(3)IO多路复用
- IO多路复用是一种同步IO模型,实现了一个线程可以监视多个文件句柄
- 一旦某个文件句柄就绪,就能通知应用程序进行相应的读写操作
- 没有文件句柄就绪就会阻塞应用程序,交出CPU
服务器端采用单线程通过select/poll/epoll等系统调用获取fd(file descriptor,文件描述符)进行accept/recv/send,使其能支持更多的并发连接请求。
1、select
select:是一种多路复用技术。其收到所有输入的文件描述符,返回哪些文件有新数据。
用数组来存储文件描述符。当调用select时,需要将fd数组拷贝到内核态,然后轮训所有fd,找出能读数据或者写数据的流。然后返回给用户态。
缺点:
- 单个进程所打开的文件描述符是有限制的,通过FD_SETSIZE设置,默认1024;
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销fd很多时会很大;
- 对 socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发 )
2、poll
- poll:本质上与select无区别,将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,但是他没有最大连接数的限制,因为它是基于链表来存储的
- 缺点
- 每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 对Socket扫描时线性扫描,采用轮询的方法、效率低
3、epoll
- epoll:epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。epoll会把哪个流发生了怎样的IO事件绑定。即当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。当调用epoll_wait检查是否有事件发生时,无需遍历整个被监听的描述符集,仅观察list就绪链表离有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。如果epoll_wait返回不为空,则把发生的时间复制到用户态,同时将事件数量返回给用户。
- 优点:没有最大并发连接的限制,能打开的fd的上限远大于1024
- 效率提升:不是轮询的方式,只有活跃可用的fd才会调用callback函数。即只管活跃的连接,跟连接总数无关
- 内存拷贝:调用epoll_ctl时拷贝进内核并保存,之后在调用不需要拷贝
- 缺点:只适用于linux
4、epoll读到一半又有新事件来了怎么办?
避免在主进程epoll再次监听到同一个可读事件,可以把对应的描述符设置为EPOLL_ONESHOT,效果是监听到一次事件后就将对应的描述符从监听集合中移除,也就不会再被追踪到。读完之后可以再把对应的描述符重新手动加上。
// 返回epoll文件描述符,size表示要监听的数目 (这个返回的fd要记得close)
int epoll_create(int size);
// epoll事件注册函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 等待事件发生,events是返回的事件链表
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll水平触发和边缘触发
- Edge Triggered(ET)边缘触发,只有数据到来才触发,不管缓存区中是否还有数据。ET 模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论 fd 中是否还有数据可读。所以在 ET 模式下,read 一个 fd 的时候一定要把它的 buffer 读完,或者遇到 EAGIN 错误。
- Level Triggered(LT)水平触发,只要有数据都会触发。LT 模式下,只要这个 fd 还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作;
(4)nginx/redis 所使用的IO模型是什么?
-
nginx的IO模型
- select
- poll
- epoll -
redis IO多路复用技术:Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用 就是为了解决这个问题而出现的。
- redis 采用网络IO多路复用技术来保证在多连接的时候, 系统的高吞吐量。 - 基于epoll实现的
13、进程的状态
进程的三种基本状态:
(1)就绪状态:进程已获得除CPU外的所有必要资源,只等待CPU时的状态。一个系统会将多个处于就绪状态的进程排成一个就绪队列。
(2)执行状态:进程已获CPU,正在执行。单处理机系统中,处于执行状态的进程只一个;多处理机系统中,有多个处于执行状态的进程。
(3)阻塞状态:正在执行的进程由于某种原因而暂时无法继续执行,便放弃处理机而处于暂停状态,即进程执行受阻。(这种状态又称等待状态或封锁状态)
通常导致进程阻塞的典型事件有:请求I/O,申请缓冲空间等。
一般,将处于阻塞状态的进程排成一个队列,有的系统还根据阻塞原因不同把这些阻塞集成排成多个队列。
当进程有I/O请求的时候会进入阻塞状态,如果该进程不放开CPU的使用权,且一直不能完成I/O请求,就会导致其他进程无法过得时间片,导致CPU空转。
14、上下文切换
CPU通过分配时间片来执行任务,当一个任务的时间片用完,就会切换到另一个任务。在切换之前会保存上一个任务的状态,当下次再切换到该任务,就会加载到这个状态。
任务从保存到再加载的过程就是一次上下文切换。
- 切出:一个线程被剥夺处理器的使用权而被暂停运行。
- 切入:一个线程被系统选中占用处理器开始或继续运行。
(1)什么时候会发生上下文切换
-
自发性上下文切换:通过调用下列方法会导致上下文切换:
- thread.sleep()
- Object.wait()
- Thread.yeild()
- Thread.join()
- LockSupport.park() -
非自发性上下文切换
- 切出线程的时间片用完
- 有一个比切出线程优先级更高的线程需要被运行
- 虚拟机的垃圾回收动作 -
上下文切换的开销包括直接开销和间接开销
- 直接开销:操作系统保存恢复上下文所需的开销;线程调度器调度线程的开销
- 间接开销:处理器高速缓存重新加载的开销;上下文切换可能导致整个一级高速缓存中的内容被冲刷,即被写入到下一级告诉缓存或主存;
(2)线程调用函数(wait、notify、join、sleep、yeild)
- wait()函数:当线程调用共享变量的wait方法时,该调用线程会被挂起。如果调用wait方法的线程没有事先获取该对象的监视器锁,则调用wait方法时线程会抛出IllegalMonitorStateException异常。调用共享变量的wait之后只会释放该共享变量上的锁。
wait() notify() || notifyAll()
使用wait要加同步锁 synchronized(),可以用notify和notifyAll唤醒
虚假唤醒
避免线程虚假唤醒(没有被其他线程通知,或者被中断,或者等待超时,就是虚假唤醒)
synchronized(obj){
while(条件不满足){
obj.wait();
}
}
- notify():一个线程调用共享对象的notify之后,会唤醒一个在该共享变量上调用wait系列方法后被挂起的线程。具体唤醒哪个线程是随机的。被唤醒的线程不能马上从wait方法返回并继续执行,它必须在获取了共享对象的监视器锁之后才可以返回。
- notifyAll():唤醒在该共享变量上被阻塞的所有线程
- join():等待线程执行中止
- sleep():线程睡眠,调用线程会暂时让出指定时间的执行权。如果在睡眠期间调用了该线程的interrupt()方法中断了该线程,则该线程会在调用sleep方法的地方抛出InterruptException异常而返回。
- yeild():当前线程让出自己的CPU使用
(3)sleep()和wait()的区别和共同点
- 区别:sleep()来自Thread ,wait()来自Object;sleep()不释放同步锁,而wait()释放同步锁;sleep()到时间会自动恢复,用于暂停执行,而wait()需要调用notify()/notifyAll()唤醒,一般用于线程通信;
- 相同点:都可以暂停线程的执行。
(4)线程中断
- interrupt():设置中断标志,但并未直接中断线程
- isInterrupted:检测当前线程是否被中断,是返回true,否则返回false
- interrupted():检测是否中断,当发现线程被中断时,返回true,且清除中断标志。
15、进程的中断
(1)硬中断:
-
硬中断是由硬件产生的,比如,像磁盘,网卡,键盘,时钟等。每个设备或设备集都有它自己的IRQ(中断请求)。基于IRQ,CPU可以将相应的请求分发到对应的硬件驱动上(注:硬件驱动通常是内核中的一个子程序,而不是一个独立的进程)。
-
处理中断的驱动是需要运行在CPU上的,因此,当中断产生的时候,CPU会中断当前正在运行的任务,来处理中断。在有多核心的系统上,一个中断通常只能中断一颗CPU(也有一种特殊的情况,就是在大型主机上是有硬件通道的,它可以在没有主CPU的支持下,可以同时处理多个中断)。
-
硬中断可以直接中断CPU。它会引起内核中相关的代码被触发。对于那些需要花费一些时间去处理的进程,中断代码本身也可以被其他的硬中断中断。
-
对于时钟中断,内核调度代码会将当前正在运行的进程挂起,从而让其他的进程来运行。它的存在是为了让调度代码(或称为调度器)可以调度多任务。
(2)软中断:
-
软中断的处理非常像硬中断。然而,它们仅仅是由当前正在运行的进程所产生的。
-
通常,软中断是一些对I/O的请求。这些请求会调用内核中可以调度I/O发生的程序。对于某些设备,I/O请求需要被立即处理,而磁盘I/O请求通常可以排队并且可以稍后处理。根据I/O模型的不同,进程或许会被挂起直到I/O完成,此时内核调度器就会选择另一个进程去运行。I/O可以在进程之间产生并且调度过程通常和磁盘I/O的方式是相同。
-
软中断仅与内核相联系。而内核主要负责对需要运行的任何其他的进程进行调度。一些内核允许设备驱动的一些部分存在于用户空间,并且当需要的时候内核也会调度这个进程去运行。
-
软中断并不会直接中断CPU。也只有当前正在运行的代码(或进程)才会产生软中断。这种中断是一种需要内核为正在运行的进程去做一些事情(通常为I/O)的请求。有一个特殊的软中断是Yield调用,它的作用是请求内核调度器去查看是否有一些其他的进程可以运行。
(3)问题解答:
-
问:对于软中断,I/O操作是否是由内核中的I/O设备驱动程序完成?
答:对于I/O请求,内核会将这项工作分派给合适的内核驱动程序,这个程序会对I/O进行队列化,以可以稍后处理(通常是磁盘I/O),或如果可能可以立即执行它。通常,当对硬中断进行回应的时候,这个队列会被驱动所处理。当一个I/O请求完成的时候,下一个在队列中的I/O请求就会发送到这个设备上。
-
问:软中断所经过的操作流程是比硬中断的少吗?换句话说,对于软中断就是:进程 ->内核中的设备驱动程序;对于硬中断:硬件->CPU->内核中的设备驱动程序?
答:是的,软中断比硬中断少了一个硬件发送信号的步骤。产生软中断的进程一定是当前正在运行的进程,因此它们不会中断CPU。但是它们会中断调用代码的流程。
如果硬件需要CPU去做一些事情,那么这个硬件会使CPU中断当前正在运行的代码。而后CPU会将当前正在运行进程的当前状态放到堆栈(stack)中,以至于之后可以返回继续运行。这种中断可以停止一个正在运行的进程;可以停止正处理另一个中断的内核代码;或者可以停止空闲进程。
(4)中断处理步骤
(1)中断请求:中断源向CPU发出中断请求
(2)中断响应
(3)保护断点和现场:以便在中断服务程序执行后正确的返回主程序。
(4)中断处理
(5)中断返回
16、CAS技术
boolean compareAndSwapLong(Object obj,long valueOffset,long except,long update)
CAS的全称为Compare-And-Swap ,它是一条CPU并发原语。它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的。
CAS 操作包含四个操作数 —— 内存位置(V)、对象中的变量的偏移量、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前 值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”
17、CAS技术存在的问题
(1)ABA问题
问题描述:当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。
解决办法:JDK1.5可以利用AtomicStampedReference类来解决这个问题,AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳,对象值和时间戳都必须满足期望值,写入才会成功
(2)循环时间长开销大
-
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
-
解决办法:JVM支持处理器提供的pause指令,使得效率会有一定的提升,pause指令有两个作用:
第一它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,
第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
(3)不能保证多个共享变量的原子操作
- 问题描述:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
- 解决办法:从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
18、Scoket
(1)什么是socket
使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字来实现网络进程之间的通信。
套接字(socket)就是对网络中不同主机上的应用进程之间双向通信的端点的抽象,一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。套接字上联应用进程,下联网络协议栈。
(2)工作流程
- 服务监听:指服务器套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态
- 客户端请求:指由客户端的台阶子提出连接请求,要连接的目标是服务器段的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后向服务器提出连接请求。
- 连接确认:指服务器端套接字坚挺到或者收到客户端套接字的连接请求,就会响应客户端套接字的请求,建立一个新的线程,并把服务器端套接字的描述发送给客户端。一旦客户确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,接受其他客户端套接字的连接请求。
(3)分类
- 面向连接服务
- 数据传输过程必须经过建立连接、维护连接和释放连接3个阶段
- 在传输过程中,各分组不需要携带目的主机的地址
- 可靠性好,由于协议复杂,通信效率不高 - 面向无连接服务
- 不需要连接各个阶段
- 每个分组都携带完整的目的主机地址,在系统中独立传送
- 由于没有顺序控制,所以接收方的分组可能出现乱序、重复和丢失现象
- 通信效率高,但可靠性不能确保
19、线程池
(1)为什么使用线程池
- 降低资源消耗;提高线程利用率,降低创建和销毁线程的消耗
- 提高响应速度;任务来了,直接就有线程可用可执行,而不是先创建线程,再执行。
- 提高线程的可管理性;线程是稀缺资源,使用线程池可以统一分配调优监控。
线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后还会循环获取工作队列中的任务来执行。
将任务派发给线程池时,会出现以下几种情况
- 核心线程池未满,创建一个新的线程执行任务。
- 如果核心线程池已满,工作队列未满,将线程存储在工作队列。
- 如果工作队列已满,线程数小于最大线程数就创建一个新线程处理任务。
- 如果超过最大线程数,按照拒绝策略来处理任务。
- 如果线程池被shutdown,则会直接拒绝。
- 当线程池中的线程数量大于核心线程时,如果某线程空闲时间超过KeepAliveTime,线程将被中止。
(2)几个重要的参数
- corePoolSize:常驻核心线程数。一般创建不会消除,常驻线程池。超过该值后如果线程空闲会被销毁
- maximumPoolSize:线程池能够容纳同时执行的线程最大数
- keepAliveTime:线程空闲时间,线程空闲时间达到该值后会被销毁,直到只剩下corePoolSize个线程为止,避免浪费内存资源。通过setKeepAliveTime来设置空闲时间。
- workQueue:工作队列,用来存放待执行的任务。如果核心线程都已被使用,还有任务进来则放入队列中,如果队列全满但还有任务进来则会开始创建新的线程。
- threadFactory:线程工厂,用来生产一组相同任务的线程。可以使用默认工厂,也可以自定义线程工厂。
- handler:拒绝策略(当线程池的任务缓存队列已满并且线程池中的线程数目达到最大,如果还有人物到来就会采取拒绝策略):
- AbortPolicy:丢弃任务并抛出(RejectedExecutionException)异常。默认策略
- DiscardPolicy:直接抛弃当前任务但不抛出异常
- CallerRunsPolicy:重新尝试提交该任务
- DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
(3)线程池创建方法
- newFixedThreadPool,创建固定大小的线程池。
- newSingleThreadExecutor,使用单线程线程池。
- newCachedThreadPool,maximumPoolSize 设置为 Integer 最大值,工作完成后会回收工作线程
- newScheduledThreadPool:支持定期及周期性任务执行,不回收工作线程。
- newWorkStealingPool:一个拥有多个任务队列的线程池。
(4)线程池的状态
- Running:能接受新提交的任务,也可以处理阻塞队列的任务。
- Shutdown:不再接受新提交的任务,但可以处理存量任务,线程池处于running时调用shutdown方
法,会进入该状态。 - Stop:不接受新任务,不处理存量任务,调用shutdownnow进入该状态。
- Tidying:所有任务已经终止了,worker_count(有效线程数)为0。 Terminated:线程池彻底终止。在- tidying模式下调用terminated方法会进入该状态。
(5)Executor框架
Executor框架目的是将任务提交和任务如何运行分离开来的机制。用户不再需要从代码层考虑设计任务
的提交运行,只需要调用Executor框架实现类的Execute方法就可以提交任务。产生线程池的函数
ThreadPoolExecutor也是Executor的具体实现类。
(6)简述阻塞队列
假设我们有若干生产者线程,另外又有若干个消费者线程。如果生产者线程需要把准备好的数据共享给消费者线程,利用队列的方式来传递数据,就可以很方便地解决他们之间的数据共享问题。但如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,那么生产者必须暂停等待一下(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕,反之亦然。
阻塞队列是生产者消费者的实现具体组件之一。当阻塞队列为空时,从队列中获取元素的操作将会被阻塞,当阻塞队列满了,往队列添加元素的操作将会被阻塞。具体实现有:
- ArrayBlockingQueue:底层是由数组组成的有界阻塞队列。
- LinkedBlockingQueue:底层是由链表组成的有界阻塞队列。
- PriorityBlockingQueue:阻塞优先队列。
- DelayQueue:创建元素时可以指定多久才能从队列中获取当前元素
- SynchronousQueue:不存储元素的阻塞队列,每一个存储必须等待一个取出操作
- LinkedTransferQueue:与LinkedBlockingQueue相比多一个transfer方法,即如果当前有消费者正
等待接收元素,可以把生产者传入的元素立刻传输给消费者。 - LinkedBlockingDeque:双向阻塞队列。
20、为什么调用start()方法时会执行run()方法,为什么不直接调用run()方法?
每个线程都是通过某个特定Thread对象所对应方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。
- start()方法来启动一个线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码;这时此线程是处于就绪状态,没有运行。然后通过此thread类调用方法run来完成其运行状态,这里方法run称为线程体,它包含了要执行的这个线程的内容,run方法运行结束,此线程终止。然后CPU再调度其他线程。
- run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。如果直接调用run(),其实就相当于是调用了一个普通的函数而已,直接代用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。
21、死锁
1、什么是死锁
死锁是指两个过两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,他们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在相互等待的进程称为死锁进程。是操作系统层面的一个错误,是死锁进程的简称。
2、死锁的条件
死锁的4个必要条件:
- 互斥条件:进程对所分配到的资源不允许其他进程进行访问,若其他的进程访问该资源,只能等待,直到占有该资源的进程使用完成之后释放该资源。
- 请求和保持条件:进程获得一定的资源之后,又提出新的资源请求,而该资源已被其他进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
- 不剥夺条件:指进程已获得的资源,在未使用玩之前,不能被剥夺,只能在使用完时自己释放。
- 环路等待条件:指在放生死锁时,必然存在一个进程–资源的环形链。
3、死锁检测
//进入JDK的安装文件夹中的bin目录,执行jps命令。
jdk>bin>jps
//例子
18776 Run
//然后执行 jstack 命令查看结果
jstack -l 18776
C:\program\Java\jdk1.6.0_45\bin>jstack -l 18776
Found 1 deadlock.
4、如何避免死锁
- 资源一次性分配,这样就不会再有请求了(破坏请求条件)。
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏占有并等待条件)。
- 可抢占资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可抢占的条件。
- 资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反,从而破坏环路等待的条件
22、volatile
当一个变量定义为 volatile 之后,将具备两种特性:
-
保证此变量对所有的线程的可见性,这里的“可见性”,当一个线程修改了这个变量的值,volatile保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。
-
禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)
Volatile -
不能保证操作的原子性
使用volatile的场景:
- 写入变量值不依赖变量的当前值时。
- 读写变量值时没有加锁。
23、ThreadLocal
每个线程都会复制一个ThreadLocal变量到自己本地的内存。不支持继承性,子进程不能访问父进程变量。
ThreadLoacl 有一个静态内部类 ThreadLocalMap,是一个定制化的hashMap,其 Key 是ThreadLocal 对象,值是 Entry 对象,为自己定义的值。每个线程的本地变量不是存储在ThreadLocal实例里,而是存放在调用线程的threadLocals变量里。当调用set方法时,在ThreadLocalMap里创建对象,并存储value值,然后通过get方法调用存储的变量值。
ThreadLocalMap是每个线程私有的。
存在的问题
- 对于线程池,由于线程池会重用 Thread 对象,因此与 Thread 绑定的 ThreadLocal 也会被重用,
造成一系列问题。 - 内存泄漏。由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾
回收后,value 依旧不会被释放,产生内存泄漏。
避免内存泄漏的方式:
- 每次使用完ThreadLocal都调用它的remove方法清楚数据
- 将ThreadLocal定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到entry的value值,进而清除掉。
- InheritableThreadLocal:支持继承
InheritableThreadLocal
让子进程可以访问父进程的本地变量。
24、可重入锁
(1)可重入性
一个线程在持有一个锁的时候,它内部能否多次申请该锁。如果可以,就称该锁为可重入锁
(2)synchronized
synchronized是java提供的一种原子性内置锁,线程执行代码在进入synchronized代码块钱会自动获取内部锁,这时候其他线程访问同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块被调用了该内置锁资源的wait系列方法时会释放该内置锁。内置锁是一个排它锁,就是当一个线程获取这个锁后,其他线程就必须等待线程释放锁后才能获取该锁。
底层实现原理:Java 对象底层都关联一个的 monitor,使用 synchronized 时 JVM 会根据使用环境找到对象的monitor,根据 monitor 的状态进行加解锁的判断。如果成功加锁就成为该 monitor 的唯一持有者,monitor 在被释放前不能再被其他线程获取。
synchronized在JVM编译后会产生monitorenter 和 monitorexit 这两个字节码指令,获取和释放monitor。这两个字节码指令都需要一个引用类型的参数指明要锁定和解锁的对象,对于同步普通方法,锁是当前实例对象;对于静态同步方法,锁是当前类的 Class 对象;对于同步方法块,锁是synchronized 括号里的对象。
执行 monitorenter 指令时,首先尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁,就把锁的计数器加 1,执行 monitorexit 指令时会将锁计数器减 1。一旦计数器为 0 锁随即就被释放。
使用方法:
- 直接修饰某个实例方法
- 修饰某个静态方法
- 修饰代码块
- 偏向锁:在锁对象头中记录一下当前获取到该锁的线程ID,该线程下次如果又来获取该锁就可以直接获取到
- 轻量级锁:由偏向锁升级而来,当一个线程获取到锁后,此时这把锁是偏向锁,此时如果有第二个线程来竞争锁,偏向锁就会升级为轻量级锁,底层使用自选锁实现,不会阻塞线程
- 如果自选次数过多人没有获取到锁,则会升级为重量级锁,重量级锁或导致线程阻塞
- 自旋锁:当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数是10),有可能在后面几次尝试中其他线程就已经释放了锁。因为阻塞和唤醒步骤都是需要操作系统去进行的,比较消耗时间。自旋锁是线程通过CAS获取预期的一个标记,这个过程线程一直在运行,没有操作太多的操作系统资源,比较轻量。
轻量级锁
(3)ReentrantLock
ReentrantLock实现了公平锁和非公平锁,底层使用AQS进行排队。
- 公平锁:设为(true),即符合先到先得原则。线程在使用lock()方法时,会先检查AQS队列中是否存在线程在排队,如果有,则当前线程也进行排队
- 非公平锁:设为(false,默认),即随机得到。在使用lock()方法时,不会检查是否有线程在排队,而是直接竞争锁。在没有对公平性有特定要求的情况下,使用非公平锁,因为公平锁会影响性能
一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程。
trylock() 尝试加锁,如果没获取到锁,返回false,该方法不会阻塞线程。
(4)Synchronized和ReentrantLock的区别
- synchronized是一个关键字,ReentrantLock是一个类
- synchronized会自动加锁和释放,ReentrantLock需要程序员手动加锁与释放
- synchronized底层是JVM层面的锁,ReentrantLock是API层面的锁
(5)AQS
AbstractQueuedSynchronizer(AQS),抽象队列同步器
25、unsafe类
对于java语言,没有直接的指针组件,一般也不能使用偏移量对某块内存进行操作。这些操作相对来说是安全的(safe)。
Java有个unsafe类,这个类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针的问题。是java并发开发的基础。
26、伪共享
(1)什么是伪共享
当多个线程需要访问的数据在同一行cache里面事,就会发生冲突,性能就会有所下降
(2)为何出现伪共享
因为缓存与内存交换数据的单位就是缓存行。
(3)如何避免
JDK8之前采用字节填充的方法,避免多个变量放入同一个缓存行中。
JDK8提供了一个sun.misc.contended注解。
27、CountDownLatch和Semaphore的区别和底层原理
- CountDownLatch表示计数器,可以给CountDownLatch设置一个数字,一个线程调用CountDownLatch的await()会被阻塞,其他线程可以调用CountDownLatch的countdown方法来对CountDownLatch中的数字减一,当数字减为0后,所有await()线程都将被唤醒。
对应底层原理就是,调用await方法的线程会利用AQS排队,一旦数字减为0,则会将AQS中排队的线程依次唤醒。 - Semaphore表示信号量,可以设置许可个数,表示同时允许最多多少个线程使用该信号量,通过acquire来获取许可,如果没有许可可用则线程阻塞,并通过AQS来排队,可以通过release()方法来释放许可,当某个线程释放了某个许可后,会从AQS中正在排队的第一个线程开始依次唤醒,直到没有空闲许可。