并发编程的优缺点
优点:
并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,
缺点:
并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如**:内存泄漏、上下文切换、线程安全、死锁**等问题。
并发编程三要素是什么?在Java中怎么保证多线程的运行安全?
并发编程的三要素:
- 原子性:是指一个或多个操作要么全部执行成功要么全部执行失败。
- 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。
- 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
出现线程安全问题的原因:
- 线程切换带来的原子性问题
- 缓存导致的可见性问题
- 编译优化带来的有序性问题
解决方法:
- JDK Atomic开头的原子类、synchronized关键字、Lock接口可以解决原子性问题
- synchronized关键字、volatile关键字、Lock接口可以解决可见性问题
- Happens-Before 规则可以解决有序性问题
并行与并发的区别
并发:同⼀时间段,一个CPU执行多个任务。
并行:单位时间内,多个CPU同时处理多个任务。
串行:多个任务由单个线程按顺序执行。
进程与线程的区别
进程:是程序的一次执行过程,或正在运行的程序。系统运行一个程序,即为一个进程的创建、运行以及消亡的过程。
线程:是程序的一条执行路径。线程又称轻量级进程。
- 进程是系统运行程序的基本单元,而线程是比进程更小的执行单位;
- 进程之间相互独立,不能共享资源,一个进程在执行过程中会产生多个线程,多个线程之间共享进程的堆和方法区;每个线程都有自己的程序计数器、虚拟机栈、本地方法栈。
sleep 和 wait 有什么区别
- wait方法与synchronized一起使用,并且会释放对象锁,sleep方法不会释放对象锁;
- wait是Object类的方法;sleep是Thread类的方法;
- 调用wait方法后,线程会变为 WAITING 等待状态;调用sleep方法后,线程会变为 TIMED_WAITING 超时等待状态;
- 进入wait状态的线程需要使用 notify 或 notifyAll唤醒;而sleep状态的线程过了等待时间就会自动被唤醒;
为什么sleep不会释放锁,wait会释放锁
为什么 wait、notify、notifyAll必须在同步方法或者同步块中被调用
当一个线程需要调用wait方法时,它必须拥有该对象的锁,接着它就会释放这个对象锁并进入阻塞状态,直到其他线程调用这个对象上的notify方法来释放这个对象锁,让其他在等待的线程能够获取到对象锁。由于这些方法都需要线程持有对象的锁,所以只能通过同步实现。
sleep与yield的区别
- 执行sleep方法后进入阻塞状态,执行yield方法后进入就绪状态;
- sleep方法声明抛出 InterruptException异常,yield方法没有声明异常;
- yield方法会给相同或更高优先级的线程运行的机会;sleep方法不考虑线程的优先级;
interrupted与isInterrupted方法的区别
- interrupt:用于中断线程。调用该方法后线程的状态将变成中断状态。
- interrupted:静态方法,查看当前中断信号并清除中断信号。若一个线程被中断,第一次调用返回true,之后调用会返回false;
- isInterrupt:查看当前中断信息是true还是false;不清除中断信号;
注:线程中断只是给线程设置一个中断状态,不会停止线程。一旦线程的被设置为中断状态,就会抛出中断异常。
notify与notifyAll方法的区别
调用了对象的wait方法,线程就会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
notifyAll会唤醒所有线程由等待池转移到锁池,然后参与锁的竞争,竞争成功则继续执行,否则留在锁池等待锁被释放后再次参与竞争。
notify只唤醒一个线程,由JVM决定唤醒哪一个线程。
线程的生命周期
- NEW :尚未启动的线程状态
- RUNNABLE :在Java虚拟机中执行的线程状态
- BLOCKED :被阻塞等待监视器锁定的线程状态
- WAITING :正在等待另一个线程执行特定动作的线程状态
- TIMED_WAITING :正在等待另一个线程执行动作达到指定等待时间的线程状态
- TERMINATED :已退出的线程状态
start方法与run方法的区别
start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。
start()方法来启动一个线程,真正实现了多线程运行。调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。
run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。
为什么调用start方法会执行run方法,为什么不能直接调用run方法
调用start方法可以启动线程并使线程进入就绪状态,而run方法只是线程的⼀个普通方法,还是在主线程中执行。
线程死锁
死锁:线程A持有线程B想要的资源,线程B持有线程A想要的资源,他们同时想要申请对方的资源,造成这两个线程相互等待而进入死锁状态。
形成死锁必要条件:
- 互斥条件:在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个进程资源的环形链。
如何避免线程死锁:
破坏互斥条件
这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
破坏请求与保持条件
一次性申请所有的资源。
破坏不剥夺条件
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件
靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
死锁与活锁的区别
活锁:任务没有被阻塞,但是由于某些条件不满足,导致一直重复尝试、失败、尝试、失败。
处于死锁的表现为一直等待,处于活锁的表现为不断的改变状态,活锁有可能自行解开,死锁则不可能;
死锁与饥饿的区别
饥饿:一个或多个线程因为其他原因一直无法获取需要的资源,导致一直无法执行的状态。
Java 中导致饥饿的原因:
1、高优先级线程吞噬所有的低优先级线程的 CPU 时间。
2、线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
3、线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。
创建线程的方式
- 继承 Thread 类;
- 实现 Runnable 接口;
- 实现 Callable 接口;
- 使用 Executors 工具类创建线程池
如果你提交任务时,线程池队列已满,这时会发生什么
- 若使用无界队列 LinkedBlockingQueue,那么可以继续添加任务到阻塞队列中等待执行,因为 LinkedBlockingQueue 无限存放任务。
- 若使用有界队列,比如ArrayBlockingQueue,任务首先会添加到有界队列中,若ArrayBlockingQueue已满,则会根据 maximumPoolSize 的值增加线程数量,若增加的线程数量还是无法满足,那么则会使用拒绝策略RejectedExecutionHanlder处理满了的任务,默认是AbortPolicy。
为什么代码会重排序
- 在单线程下不能改变程序运行的结果;
- 存在数据依赖关系的不允许重排序
as-if-serial规则和happens-before规则的区别
- as-if-serial规则保证单线程内程序的执行结果不被改变;happens-before规则保证多线程程序的执行结果不被改变;
- as-if-serial规则使单线程程序按顺序执行;happens-before规则使多线程程序按happens-before指定的顺序执行;
- as-if-serial与happens-before规则的目的是为了在不改变程序执行结果的情况下,尽可能提高程序的执行并行度。
Java中的锁
公平锁和非公平锁
公平锁:多个线程在等待同一个锁时,必须按申请锁的先后顺序来获得锁。
非公平锁:多个线程相互竞争(抢占式)来获得锁。
注:公平锁可以使用java中ReentrantLock类实现。例如new ReentrantLock(true)
自旋锁
说白了就是让一个线程执行一个 while(true) 的无限循环。如果一个线程自旋转超过限定次数(默认10次)还没有成功获取锁资源,就会挂起线程。
特点:若锁被占用的时间很短,自旋等待的效果就会非常好;若锁被占用的时间过长,自旋的线程就会浪费CPU资源,降低性能。
可重入锁
允许同一个线程多次获取同一把锁,java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
注:在java中ReentrantLock、synchronized都是可重入锁。可重入锁最大的作用就是避免死锁。
共享锁和排它锁
共享锁:若事务A对数据D加了共享锁,则其他事务只能对数据D加共享锁,不能加排它锁。获取共享锁的事务只能读数据,不能修改数据。
排它锁:若事务A对数据D加了排它锁,则其他事务不能再对数据D加任何类型的锁。获取排它锁的事务对数据可读可写。
读写锁
一个资源能被多个读线程访问,或被一个写线程访问但不能同时存在读线程。
互斥锁
一次最多只能有一个线程持有的锁。
注:synchronized、Lock就是互斥锁。
协程是什么
在单线程里实现多任务的高度,并在单线程里维持多个任务间的切换。
synchronized关键字
作用:用来实现线程同步,在多线程中,保证synchronized代码块不被多个线程同时执行。
synchronized主要的三种使用方式:
- 修饰实例方法:给当前对象实例加锁。进入同步代码前要获取当前对象实例的锁。
- 修饰静态方法:给当前类加锁。如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁
- 修饰代码块:指定加锁对象。进入同步代码块前要获取指定的对象锁。
注: 尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能
synchronized底层原理
synchronized使用时不需要显示加锁和解锁过程的,但是编译后会在同步代码块的前后添加上monitorenter 和 monitorexit指令。通过执行monitorenter 指定获取锁,执行完代码之后会通过 monitorexit 指令释放锁。java对象的内存布局分为:对象头、实例数据和对齐填充,而对象头又可以分为Markword和类型指针Klass。Markword用来存储对象自身的运行时数据,例如HashCode、分代年龄和锁标记位。
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过javap -c -v SysnchronizedDemo
反编译代码后,查看字节码结构,如下图所示:
monitorenter
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit
- 执行monitorexit的线程必须是objectref所对应的monitor的所有者。
- 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
为什么会为两个 monitorexit 指令
第二个monitorexit指令是保证在异常情况下,锁也可以得到释放,避免死锁情况。
synchronized可重入原理
可重入锁:线程可以多次获取同一把锁。
底层原理:底层通过维护一个计数器,当线程获取该锁时,计数器加1;当线程释放该锁时,计数器减1;当计数器为0时,表示该锁没有被任何线程持有。
synchronized锁升级原理
锁有4种状态,根据优先级从低到高分别是无状态锁、偏向锁、轻量级锁、重量级锁。锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态
偏向锁
锁会偏向第一个获取它的线程,在接下来的执行过程中该锁没有其他的线程获取,则持有偏向锁的线程永远不需要再进行同步。
轻量级锁
重量级锁
锁升级过程:初次执行synchronized代码块时,锁对象会从无锁升级为偏向锁,通过CAS修改对象头中的锁标识,再次执行synchronized代码块时,会先判断对象头的 threadid 是否与当前线程 id 相同,若不相同,则升级偏向锁为轻量级锁。没有抢到锁的线程将自旋一定次数来获取锁,执行一定次数之后,若还没有正常获取到锁,就会把锁从轻量级升级为重量级锁。
用户态和内核态
讲一下用户态和内核态
- 用户态(user mode):用户态运行的进程可以直接读取用户程序的数据。
- 内核态(kernel mode):内核态运行的进程或程序可以访问计算机的任何资源,不受限制。
所有的系统调用都会进入内核态吗?
与内核态级别的资源有关的操作,都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。
用户态切换到内核态的方式
系统调用
:用户程序通过OS调用内核程序开放的接口来执行程序。异常
:当CPU执行用户态的程序时,发生了异常。这时当前用户态的程序会切换到处理此异常的内核的程序中去。硬件设备的中断
用户态和内核态什么时候进行切换
程序都是运行在用户态
的,但有时程序确实需要做一些内核的事,例如从硬盘读取数据或从硬盘获取输入,而唯一可以做这些事的就是OS,所以程序就需要先操作系统请求以程序的名义来执行这些操作。
synchronized中依赖的monitor也需要依赖OS完成,因此需要用户态到内核态的切换
双重校验锁实现对象单例(线程安全)
public class SingleTon{
private volatile static SingleTon instance;
private SingleTon(){}
public static SingleTon getInstance(){
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if(instance == null){
synchronized(SingleTon.class){
if(instance == null){
instance = new SingleTon();
}
}
}
return instance;
}
}
instance 采用 volatile 关键字修饰也很有必要, instance = new SingleTon();这段代码其实是分为三步执行:
- 为 instance 分配内存空间
- 初始化 instance
- 将 instance 指向分配的内存地址
因为JVM具有指令重排的特性,执行顺序可能会变成 1->3->2,导致实例还没有被初始化。所以使用 volatile 可以禁止 JVM 的指令重排
,保证在多线程环境下也能正常运行。
synchronized 和 Lock 的区别
-
synchronized不需要手动加锁和释放锁,当出现异常时,会自动释放锁,不会造成死锁;Lock需要手动加锁和释放锁;
-
synchronized是关键字,可以给类、方法、代码块加锁;Lock是接口,只能给代码块加锁;
synchronized 和 ReentrantLock 的区别
- 都是可重入锁;
- synchronized是关键字,可以修饰类,方法、代码块;ReentrantLock是类,只适用于代码块;
- synchronized不需要手动加锁和释放锁,当出现异常时,会自动释放锁,不会造成死锁;Lock需要手动加锁和释放锁;
synchronized 和 volatile 的区别
- volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。
- volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
线程A进入一个对象的 synchronized 方法 A 之后,其它线程是否可进入此对象的 synchronized 方法 B?
不能,因为线程A进入了A方法,说明线程A已经获取到了对象的锁,其他线程想进入B方法就只能在等待池中等待对象锁。
ReentrantLock原理分析
ConcurrentHashMap原理分析
乐观锁和悲观锁的理解及实现,有哪些实现方式
乐观锁:乐观的思想,认为每次获取数据时都不会有用户会修改,所以不上锁,但是在更新的时候会判断一下其他用户是否更新过数据。
悲观锁:悲观的思想,认为每次获取数据时都会有其他用户会修改,所以每次获取数据时都会加锁。
乐观锁的实现:
- 通过版本号标识来确定读取的数据与提交数据的一致性。提交后修改版本号,数据不一致时可以采用丢弃和再次尝试的策略。
- 当多个线程尝试用CAS来更新同一个变量时,只会有一个线程更新成功,其他线程都更新失败,失败的线程并不会被挂起,而是告知这次竞争中失败,并可以再次尝试。CAS包括三个操作数,V内存位置,A期望值,B新值,当内存位置V与期望值A相等时,CPU会将内存中的值更新为新值B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a 线程获取地址里面的值被b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行。
synchronizedMap与ConcurrentHashMap的区别
- synchronizedMap一次锁住整个类来保证线程安全,所以每次只能有一个线程来访问。
- ConcurrentHashMap使用分段锁来保证在多线程下的性能。一次锁住一个桶。ConcurrentHashMap 默认将hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁当前需要用到的桶。
AQS原理分析
核心思想:若被请求的共享资源空闲,则当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。若被请求的共享资源被占用,就将暂时获取不到锁的线程加入CLH队列中。
CLH队列
线程池
池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
线程池的状态
running:正常状态,接受新任务,处理等待队列中的任务;
shutdown:不接受新任务提交,但是会继续处理等待队列中的任务;
stop:不接受新任务提交,不在处理等待队列中的任务,中断正在执行的线程任务;
tidying:所以有任务都销毁了,workCount为0,并执行钩子方法 terminated();
terminated:钩子函数执行完后,线程池就会进入 terminated 状态。
线程池中submit和execute方法的区别
- 接收参数:submit可以执行Runnable和Callable类型的任务;execute只能执行Runnable类型任务;
- 返回值:submit可以返回一个Future对象;execute无返回值;
ThreadPoolExecutor构造函数参数分析
- corePoolSize:核心线程数。线程定义了最小可以同时运行的线程数量。
- maximuPoolSize:最大线程数。线程中允许存在的工作线程的最大数量。
- workQueue:当新任务入队时会先判断当前运行的线程数量是否达到corePoolSize,若达到,任务就会被存在放在队列中。
- keepAliveTime:线程池中的线程数量大于核心线程数时,若这些没有新任务提交,核心线程数的线程不会立即销毁,而是等待一段时间,直接等待的时间超过 keepAliveTime 都会被回收销毁。
- unit:keepAliveTime 参数的时间单位。
- threadFactory:
- hander:线程池任务队列超过最大线程数后的拒绝策略。
ThreadPoolExecutor饱和策略
定义:若当前同时运行的线程数量达到最大线程数并且队列中任务已满,ThreadPoolTaskExecutor定义了一些策略:
- ThreadPoolExecutor.AbortPolicy:抛出异常 RejectedExecutionException 来拒绝新任务的处理。
- ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃。
- ThreadPoolExecutor.DiscardOldesPolicy:丢弃最早未处理的任务请求。
- ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务。
线程池的原理
参考文章: