1. 并发和并行
2. 进程和线程
对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。
而在多个进程之间切换的时候,需要进行上下文切换。但是上下文切换势必会耗费一些资源。于是人们考虑,能不能在一个进程中增加一些“子任务”,这样减少上下文切换的成本。比如我们使用Word的时候,它可以同时进行打字、拼写检查、字数统计等,这些子任务之间共用同一个进程资源,但是他们之间的切换不需要进行上下文切换。
在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程。
随着时间的慢慢发展,人们进一步的切分了进程和线程之间的职责。把进程当做资源分配的基本单元,把线程当做执行的基本单元,同一个进程的多个线程之间共享资源
3. 类变量,成员变量和局部变量
Java中共有三种变量,分别是类变量、成员变量和局部变量。他们分别存放在JVM的方法区、堆内存和栈内存中
public class Variables{
/**
* 类变量
*/
private static int a;
/**
* 成员变量
*/
private int b;
/**
* 局部变量
* @param c
*/
public void test(int c){
int d;
}
}
因为方法区和推内存是线程共享的,所以a和b是共享变量,c和d是局部变量
4. 线程安全
线程安全是编程中的术语,指某个函数、函数库在并发环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。 简单来说,就是多个线程同时访问共享变量的时候,得到的结果和我们预期的一样,就是线程安全。这里所谓的预期,其实就是要满足所谓的原子性、有序性和可见性
5. 并发编程的三个特性
原子性,有序性,可见性
6. 原子性
原子性是指:一个操作是不可中断的,要全部执行完成,要不就都不执行。 数据库事务中,保证原子性通过事务的提交和回滚,但是在并发编程中,是不涉及到回滚的。
所以,并发编程中的原子性,强调的是一个操作的不可分割性。所以,在并发编程中,原子性的定义不应该和事务中的原子性完全一样。他应该定义为:一段代码,或者一个变量的操作,在没有执行完之前,不能被其他线程执行
7. 有序性
有序性即程序执行的顺序按照代码的先后顺序执行。 Java程序中天然的有序性可以总结为一句话:
如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的
为了提升指令的并行度,编译器和处理器可能会对指令进行重排序,导致和程序员看到的执行顺序可能不一致。 如果不能满足有序性则可能得到的结果和预期不同
比如编译器的指令重排序,处理器指令重排序,高速缓存的存在等都会导致指令重排序从而导致最终执行的机器指令顺序改变。
重排序会影响有序性
- 但是它不会改变单线程的执行结果,即最终的结果是和单线程的一致
- 未能正确同步的多线程程序就无法保证多线程程序性的有序性正确,最终导致执行的结果和预期不一致
- 正确同步了的多线程会在 JMM 的控制下即使改变了有序性,最终执行结果也和预期一致
8. 可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
如果无法保证可见性,那么就是说多个线程之间的本地内存中对于同一个变量的值是不同的。 线程1从主存中取出a=1,保存到自己的工作内存。 线程1在自己的工作内存中操作a=a+1。 线程2从主存中取出a,这时a还是1,自然会出错
9. 线程状态
Java中线程的状态分为6种:
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java线程中将就绪(READY)和运行中(RUNNING)两种状态笼统的称为“运行”
- 就绪(READY):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中并分配cpu使用权
- 运行中(RUNNING):就绪(READY)的线程获得了cpu 时间片,开始执行程序代码
- 阻塞(BLOCKED):表示线程阻塞于锁
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回
- 终止(TERMINATED):表示该线程已经执行完毕。 状态流转如图。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uA68uhCk-1580314377455)(https://images.zsxq.com/Fq3fcTdqPOcu_n8M0w1KkeWiiN75?imageMogr2/auto-orient/thumbnail/540x/format/jpg/blur/1x0/quality/75&e=1582991999&token=kIxbL07-8jAj8w1n4s9zv64FuZZNEATmlU_Vm6zD:6HvLiOI0MjX6OdxURQYlpvY-FtU=)]
10. JVM采用的线程调度模型
常见的线程调度模型有协同式线程调度和抢占式调度模型
- 协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题
- 抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题。系统会让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃CPU
11. 线程优先级
虽然Java线程调度是系统自动完成的(因为线程调度方式是抢占式调度模型),但是我们还是可以“建议”系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点——这项操作可以通过设置线程优先级来完成。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行
12. 守护线程
在Java中有两类线程:User Thread(用户线程)、Daemon Thread(守护线程) 。
用户线程一般用户执行用户级任务,而守护线程也就是“后台线程”,一般用来执行后台任务,守护线程最典型的应用就是GC(垃圾回收器)。 这两种线程其实是没有什么区别的,唯一的区别就是Java虚拟机在所有“用户线程”都结束后就会退出。
我们可以通过使用setDaemon()方法通过传递true作为参数,使线程成为一个守护线程。我们必须在启动线程之前调用一个线程的setDaemon()方法。否则,就会抛出一个java.lang.IllegalThreadStateException。
在Daemon线程中产生的新线程也是Daemon的
13. ThreadLocal
- ThreadLocal是java.lang下面的一个类,是用来解决java多线程程序中并发问题的一种途径
- 通过为每一个线程创建一份共享变量的副本来保证各个线程之间的变量的访问和修改互相不影响
- ThreadLocal存放的值是线程内共享的,线程间互斥的,主要用于线程内共享一些数据,避免通过参数来传递,这样处理后,能够优雅的解决一些实际问题。 比如一次用户的页面操作请求,我们可以在最开始的filter中,把用户的信息保存在ThreadLocal中,在同一次请求中,在使用到用户信息,就可以直接到ThreadLocal中获取就可以了。 还有一个典型的应用就是保存数据库连接,我们可以在第一次初始化Connection的时候,把他保存在ThreadLocal中
- ThreadLocal有四个方法,分别为
- initialValue 返回此线程局部变量的初始值
- get 返回此线程局部变量的当前线程副本中的值。如果这是线程第一次调用该方法,则创建并初始化此副本
- set 将此线程局部变量的当前线程副本中的值设置为指定值。许多应用程序不需要这项功能,它们只依赖于initialValue()方法来设置线程局部变量的值
- remove 移除此线程局部变量的值。
14. 什么是线程池
线程池是池化技术的一种典型实现,所谓池化技术就是提前保存大量的资源,以备不时之需。在机器资源有限的情况下,使用池化技术可以大大的提高资源的利用率,提升性能等
在编程领域,比较典型的池化技术有: 线程池、连接池、内存池、对象池等。 线程池,说的就是提前创建好一批线程,然后保存在线程池中,当有任务需要执行的时候,从线程池中选一个线程来执行任务
15. 如何使用线程池
16. 为什么不建议用Executors
Executors底层是通过LinkedBlockingQueue实现的。 LinkedBlockingQueue是一个用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。 不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。
也就是说,如果我们不设置LinkedBlockingQueue的容量的话,其默认容量将会是Integer.MAX_VALUE。 而Executors创建线程池时,并未指定容量。此时,LinkedBlockingQueue就是一个无边界队列,对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况下就有可能因为任务过多而导致内存溢出问题
17. Executors
可以创建几种线程
Executors的创建线程池的方法,创建出来的线程池都实现了ExecutorService接口
常用方法有以下几个:
- newFiexedThreadPool(int Threads):创建固定数目线程的线程池
- newCachedThreadPool():创建一个可缓存的线程池,调用execute 将重用以前构造的线程(如果线程可用)。如果没有可用的线程,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有 60 秒钟未被使用的线程
- newSingleThreadExecutor()创建一个单线程化的Executor
- newScheduledThreadPool(int corePoolSize)创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类
18. Java中如何创建线程
在Java中,共有四种方式可以创建线程,分别是继承Thread类创建线程、实现Runnable接口创建线程、通过Callable和FutureTask创建线程以及通过线程池创建线程
我们都知道,Java是不支持多继承的,所以,使用Runnbale接口的形式,就可以避免要多继承 。比如有一个类A,已经继承了类B,就无法再继承Thread类了,这时候要想实现多线程,就需要使用Runnable接口了
Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法call(),和Runnable接口中的run()方法不同的是,call()方法有返回值
FutureTask可用于异步获取执行结果或取消执行任务的场景。通过传入Callable的任务给FutureTask,直接调用其run方法或者放入线程池执行,之后可以在外部通过FutureTask的get方法异步获取执行结果,因此,FutureTask非常适合用于耗时的计算,主线程可以在完成自己的任务后,再去获取结果
19. 缓存一致性
在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致
20. 内存一致性
缓存一致性(Cache Coherence),解决是多个缓存副本之间的数据的一致性问题。
内存一致性(Memory Consistency),保证的是多线程程序访问内存时可以读到什么值。
内存一致性就是程序员(编程语言)、编译器、CPU间的一种协议。这个协议保证了程序访问内存时会得到什么值。如果没有Memory Consistency,程序员写的程序代码的输出结果是不确定的。
简单点说,内存一致性,就是保证并发场景下的程序运行结果和程序员预期是一样的(当然,要通过加锁等方式),包括的就是并发编程中的原子性、有序性和可见性。而缓存一致性说的可以简单的类比程并发编程中的可见性
21. 缓存一致性和可见性
- 缓存一致性指的是多个CPU的多级缓存中数据不一致的现象
- Java并发编程中的可见性指的是多个线程的本地内存中数据互相不可见的现象
- 二者可以类比,但是并不完全一样
22. 如何解决缓存一致性
-
通过在总线加LOCK#锁的方式
因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从其内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题
-
通过缓存一致性协议(Cache Coherence Protocol)
缓存一致性协议(Cache Coherence Protocol),最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
MESI的核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
在MESI协议中,每个缓存可能有有4个状态,它们分别是:
- M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中
- E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中
- S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中
- I(Invalid):这行数据无效
值得注意的是,传统的MESI协议中有两个行为的执行成本比较大。 一个是将某个Cache Line标记为Invalid状态,另一个是当某Cache Line当前状态为Invalid时写入新的数据。所以CPU通过Store Buffer和Invalidate Queue组件来降低这类操作的延时。
如图:  当一个CPU进行写入时,
- 首先会给其它CPU发送Invalid消息,然后把当前写入的数据写入到Store Buffer中。
- 然后异步在某个时刻真正的写入到Cache中。
- 当前CPU核如果要读Cache中的数据,需要先扫描Store Buffer之后再读取Cache。
- 但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache之后才会触发失效操作
- 而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate Queue中,随后异步将其设为Invalid状态
- 和Store Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题
23. 时间片
现在我们用到操作系统,无论是Windows、Linux还是MacOS等其实都是多用户多任务分时操作系统
但是实际上,对于单CPU的计算机来说,在CPU中,同一时间是只能干一件事儿的。 为了看起来像是“同时干多件事”,分时操作系统是把CPU的时间划分成长短基本相同的时间区间,即”时间片”,通过操作系统的管理,把这些时间片依次轮流地分配给各个“用户”使用
如果某个“用户”在时间片结束之前,整个任务还没有完成,“用户”就必须进入到就绪状态,放弃CPU,等待下一轮循环。此时CPU又分配给另一个“用户”去使用。
不同的操作系统,在选择“用户”分配时间片的调度算法是不一样的,常用的有FCFS、轮转、SPN、SRT、HRRN、反馈
24. 时间片导致的问题
在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作
在单线程中,一个读改写就算不是原子操作也没关系,因为只要这个线程再次被调度,这个操作总是可以执行完的。但是在多线程场景中可能就有问题了。因为多个线程可能会对同一个共享资源进行操作
25. 指令重排
在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。
但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。这种优化就是指令重排。 除了CPU会对指令进行重排以外,JVM也会有类似的指令重排优化
26. 指令重排导致的问题
在某些情况下,指令重排会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。
由于指令重排,CPU就会对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能产生有序性问题
27. 计算机内存模型
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
28. JMM
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同
JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
29. Java并发关键字
volatile、synchronized、final、juc
30. JVM中线程共享的区域
Java堆和方法区。
在JVM中,堆内存是所有线程共享的。堆中只包含对象,没有其他东西。所以,堆上也无法保存基本类型和对象引用。堆和栈分工明确。但是,对象的引用其实也是对象的一部分。这里值得一提的是,数组是保存在堆上面的,即使是基本类型的数据,也是保存在堆中的。因为在Java中,数组是对象。
除了堆,还有一部分数据可能保存在JVM中的方法区中,比如类的静态变量。方法区和栈类似,其中只包含基本类型和对象应用。和栈不同的是,方法区中的静态变量可以被所有线程访问到。
31. 对象锁和类锁
为了协调多个线程之间的共享数据访问,虚拟机给每个对象和类都分配了一个锁。这个锁就像一个特权,在同一时刻,只有一个线程可以“拥有”这个类或者对象。如果一个线程想要获得某个类或者对象的锁,需要询问虚拟机。当一个线程向虚拟机申请某个类或者对象的锁之后,也许很快或者也许很慢虚拟机可以把锁分配给这个线程,同时这个线程也许永远也无法获得锁。当线程不再需要锁的时候,他再把锁还给虚拟机。这时虚拟机就可以再把锁分配给其他申请锁的线程。
类锁其实通过对象锁实现的。因为当虚拟机加载一个类的时候,会会为这个类实例化一个 java.lang.Class 对象,当你锁住一个类的时候,其实锁住的是其对应的Class 对象
32. synchronized
synchronized 是 Java 中控制并发的关键字,主要有两种用法:同步方法和同步代码块。
同步方法:public synchronized void get(){ … }
同步代码块:synchronized (object){ … }
不论 synchronized 是修饰方法还是修饰代码块,它锁住的都是对象,在同一时间,只能被单个线程访问
33. 用synchronized实现类锁
由于一个class不论被实例化多少次,其中的静态方法和静态变量在内存中都只有一份。所以,一旦一个静态的方法被申明为synchronized。此类所有的实例化对象在调用此方法,共用同一把锁,我们称之为类锁。对象锁是用来控制实例方法之间的同步,类锁是用来控制静态方法(或静态变量互斥体)之间的同步。
类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的。 java类可能会有很多个对象,但是只有1个Class对象,也就是说类的不同实例之间共享该类的Class对象。Class对象其实也仅仅是1个java对象,只不过有点特殊而已。由于每个java对象都有1个互斥锁,而类的静态方法是需要Class对象。所以所谓的类锁,不过是Class对象的锁而已。获取类的Class对象有好几种,最简单的就是 [类名.class]的方式。
34. 用synchronized实现对象锁
当一个对象中有synchronized method或synchronized block的时候调用此对象的同步方法或进入其同步区域时,就必须先获得对象锁。如果此对象的对象锁已被其他调用者占用,则需要等待此锁被释放。(方法锁也是对象锁)
java的所有对象都含有1个互斥锁,这个锁由JVM自动获取和释放。线程进入synchronized方法的时候获取该对象的锁,当然如果已经有线程获取了这个对象的锁,那么当前线程会等待;synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁。这里也体现了用synchronized来加锁的1个好处,方法抛异常的时候,锁仍然可以由JVM来自动释放
35. synchronized同步方法的实现
方法级的同步是隐式的。
同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。
这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放
36. synchronized同步代码块的实现
同步代码块使用monitorenter和monitorexit两个指令实现。 可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁
37. synchronized实现原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。这两个字节码指令,在Java中对应的关键字就是synchronized。
通过monitorenter和monitorexit指令,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是,他并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。
38. synchronized实现可见性
被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。 所以,synchronized关键字锁住的对象,其值是具有可见性的。
39. synchronized实现有序性
有序性即程序执行的顺序按照代码的先后顺序执行。 除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。”
这里需要注意的是,synchronized是无法禁止指令重排和处理器优化的。也就是说,synchronized无法避免上述提到的问题。 那么,为什么还说synchronized也提供了有序性保证呢? 这就要再把有序性的概念扩展一下了。Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。
不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。 as-if-serial语义保证了单线程中,指令重排是有一定的限制的,而只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的。当然,实际上还是有重排的,只不过我们无须关心这种重排的干扰。
由于synchronized修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。
40. monitor
moniter也称为管程,在OS中是一种数据结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
在Java中, Java提供了同步机制、互斥锁机制,这个机制保证了在同一时刻只有一个线程能访问共享资源。这个机制的保障来源于监视锁Monitor,每个对象都拥有自己的监视锁Monitor。其存储在对象头的MarkWord区域
Monitor其实是一种同步工具,也可以说是一种同步机制,它通常被描述为一个对象 ,对象中的方法都是互斥执行,同时它由ObjectMonitor 实现,有三个数据结构
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
41. 重量级锁
sychronized
加锁的时候,会调用objectMonitor的enter
方法,解锁的时候会调用exit
方法。事实上,只有在JDK1.6之前,synchronized
的实现才会直接调用ObjectMonitor的enter
和exit
,这种锁被称之为重量级锁。因为: Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到核心态,因此状态转换需要花费很多的处理器时间,对于代码简单的同步块(如被synchronized
修饰的get
或set
方法)状态转换消耗的时间有可能比用户代码执行的时间还要长,所以说synchronized
是java语言中一个重量级的操纵。
同时,还有一个种说法:
对象锁标志位共分为四种状态:无锁
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁,竞争锁的线程使用自旋会消耗 CPU | 追求响应时间,同步代码块执行速度非常快 |
重量级锁 | 线程竞争不需要自旋,不会消耗 CPU | 线程阻塞,响应时间慢 | 追求吞吐量,同步代码块执行速度较长 |
- 锁只能升级,不能降级
42. synchronized的优化
-
偏向锁:经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单的测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。测试成功则获得该锁,失败会去检查MarkWord中偏向锁的标志。如果标志为0,则用CAS竞争锁。如果设置为1,即当前是偏向锁, 则尝试使用 CAS 将对象头的偏向锁指向当前线程。
-
轻量级锁:偏向锁膨胀为轻量级锁之后,线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁,当自旋达到一定次数之后未获得锁,便会膨胀成重量级锁。
-
锁消除:JIT通过逃逸分析技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。 如果是,则消除synchronized,即不用加锁
-
适应性自旋锁:线程加锁的时间一般都很短,所以下一个需要获得锁的线程等一下在阻塞,这个等一下的过程就叫自旋。这种优化方法叫自旋锁。自旋锁在1.4就有,只不过默认的是关闭的,jdk1.6是默认开启的
-
锁粗化:JIT优化,防止对同一个对象连续加锁和解锁。增大了锁的粒度
43. volatile
volatile
是轻量级的 synchronized,是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块。 底层通过实现内存屏障(loadstore,storeload,loadload,storestore)保证了多线程访问共享变量的有序性,可见性。但是不能保证原子性
44. volatile实现可见性
对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中
所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的
45. volatile实现有序性
volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等。 普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。
volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load、add、save。
46. 什么是内存屏障
47. volatile为什么不能解决原子性问题
基本所有的赋值和取值操作是原子性的,但是一个变量的几个操作形成的操作序列要保证原子性就需要对这个变量加锁,保证在操作序列过程中不发生其他赋值操作。 但是volatile并没有加锁的功能
volatile不能保证原子性是因为它只是在读或写的前后插入内存屏障,但对于读到数据之后和写会主内存之前,这之间的操作是没有内存屏障的保障的。
48. volatile&synchronized
首先,synsynchronized其实是一种加锁机制,那么既然是锁,天然就具备以下几个缺点:
-
有性能损耗:虽然在JDK 1.6中对synchronized做了很多优化,如如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。但是他毕竟还是一种锁。所以,无论是使用同步方法还是同步代码块,在同步操作之前还是要进行加锁,同步操作之后需要进行解锁,这个加锁、解锁的过程是要有性能损耗的
-
产生阻塞:无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的。基于Monitor对象,当多个线程同时访问一段同步代码时,首先会进入Entry Set,当有一个线程获取到对象的锁之后,才能进行The Owner区域,其他线程还会继续在Entry Set等待。并且当某个线程调用了wait方法后,会释放锁并进入Wait Set等待。所以,synchronize实现的锁本质上是一种阻塞锁。
除了前面我们提到的volatile比synchronized性能好以外,volatile其实还有一个很好的附加功能,那就是禁止指令重排。因为volatile借助了内存屏障来帮助其解决可见性和有序性问题,而内存屏障的使用还为其带来了一个禁止指令重排的附件功能,所以在有些场景中是可以避免发生指令重排的问题的
49. sleep和wait
Object.wait()和Thread.sleep(),都可以让线程进入阻塞状态
-
sleep()方法使执行中的线程主动让出CPU,进入阻塞状态(block),不会释放对象锁,在sleep指定时间后CPU便会到可执行状态(runnable)。注意,runnable并不表示线程一定可以获得CPU。需要等待被CPU调度后进入运行中
- sleep的缺点:1.若睡眠时间过长,难以确保及时发现条件变化。2.若睡眠时间过短,难以降低开销,此时虽然能迅速发现条件变化,但频繁阻塞唤醒线程,消耗更多处理器资源
-
wait()方法使执行中的线程主动让出CPU,会放弃对象锁,进入等待队列(waitting queue)中,只有调用了notify()/notifyAll()方法,才会从等待队列中被移出,重新获取锁之后,便可再次变成可执行状态(runnable)。 sleep()方法可以在任何地方使用;wait()方法则只能在同步方法或同步块中使用;
- wait和notify,notifyAll组成经典的等待唤醒机制才是有意义的,单独使用作用不大。wait(long)是没有通知超时返回的方法,此方法单独使用,和sleep效果一样
50. notify和notifyAll
notify不能保证一定将其唤醒,因为notify只能唤醒队列上的一个线程,而notifyAll则能够使队列上的所有线程被唤醒。
wait和notify,notifyAll组成经典的等待唤醒机制才是有意义的,单独使用作用不大
51. 死锁
死锁是指两个或两个以上的进程(或线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程
有经验才能找到工作,有工作才能有经验
52. 产生死锁的条件
- 互斥条件:一个资源每次只能被一个进程使用
- 占有且等待:一个进程因请求资源而阻塞时,对已获得的资源保持不放
- 不可强行占有:进程已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
- 第四个条件是前三个条件同时存在时产生的结果,只要破坏这四个条件之一,死锁就可防止
53. 死锁防止
- 破坏互斥:使资源可同时访问而不是互斥使用。该办法对于磁盘适用,对于磁带机、打印机等多数资源不仅不能破坏互斥使用条件,还要加以保证
- 破坏占有和等待:静态分配可以破坏占有和等待条件。静态分配是指一个进程必须在执行前就申请它所需要的全部资源,并且直到它所需要的资源都得到满足后才开始执行。资源利用率低
- 破坏不剥夺条件:即采用剥夺式调度方法。当进程申请资源未获准许时,在等待前主动释放资源。剥夺调度方法目前只适用于内存资源和处理器资源
- 破坏循环等待条件:层次分配策略将资源被分成多个层次,进程按照由低到高的层次顺序申请和得到资源,按照由高到低的层次顺序释放资源。当进程得到某一层的一个资源后,如果需要申请该层的另一个资源,则必须先释放该层中的已占资源
54. 为什么wait, notify 和 notifyAll不在thread类里面
因为Java锁的目标是对象,所以wait、notify和notifyAll针对的目标都是对象,所以把他们定义在Object类中。wait和notify方法必须先拿到锁对象的monitor所有权。
55. run()和start()的区别
run方法是线程真正执行的方法。 而我们创建好线程之后,想要启动这个线程,则需要调用其start方法。所以,start方法是启动一个线程的入口。 如果在创建好线程之后,直接调用其run方法,那么就会在单线程中直接运行run方法,不会起到多线程的效果。
线程执行start方法是去通知jvm启动线程(native方法start0),至于何时启动由jvm的线程调度器决定,线程启动后会调用run方法去执行相关逻辑。直接调用run方法相当于本线程执行run方法。
56. runnable和callable
Runnable接口和Callable接口都可以用来创建新线程,实现Runnable的时候,需要实现run方法;实现Callable接口的话,需要实现call方法。
Runnable的run方法无返回值,Callable的call方法有返回值,
类型为Object Callable中可以够抛出checked exception,而Runnable不可以。
Callable和Runnable都可以应用于executors。而Thread类只支持Runnable
57. 同步集合和并发集合
同步集合可以简单地理解为通过synchronized来实现同步的集合。如果有多个线程调用同步集合的方法,它们将会串行执行。 在Java中,同步集合主要包括2类:
-
Vector、Stack、HashTable
-
Collections类中提供的静态工厂方法创建的类
并发集合 是jdk1.5新增特性,增加了并发包java.util.concurrent.*。 如其中的ConcurrentHashMap定义了线程安全的复合操作。
58. 同步集合一定是线程安全的吗
同步容器直接保证单个操作的线程安全性,但是无法保证复合操作的线程安全,遇到这种情况时,必须要通过主动加锁的方式来实现。如vector.remove()和vector.size()是线程安全的,但是把这两个方法复合到一个方法,就是线程不安全的,要加锁才可以
59. CopyOnWrite
JUC中的CopyOnWriteArrayList和CopyOnWriteArraySet是Copy-On-Write的两种实现。
Copy-On-Write容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
CopyOnWriteArrayList中add/remove等写方法是需要加锁的,而读方法是没有加锁的。 这样做的好处是我们可以对CopyOnWrite容器进行并发的读,
当然,这里读到的数据可能不是最新的。因为写时复制的思想是通过延时更新的策略来实现数据的最终一致性的,并非强一致性。
60. AQS
AbstractQueuedSynchronizer (抽象队列同步器,以下简称 AQS)出现在 JDK 1.5 中。
AQS 是很多同步器的基础框架,比如 ReentrantLock、CountDownLatch 和 Semaphore 等都是基于 AQS 实现的。除此之外,我们还可以基于 AQS,定制出我们所需要的同步器。
AQS 的使用方式通常都是通过内部类继承 AQS 实现同步功能,通过继承 AQS,可以简化同步器的实现。
61. ReentrantLock
- 基于API,依靠AQS实现
- 可重入锁
- 等待可中断
- 可实现公平和非公平锁
- 实现选择性通知(锁可以绑定多个条件)
62. ReentrantLock和synchronized
二者相同点是,都是可重入的
二者也有很多不同,如:
synchronized是Java内置特性,而ReentrantLock是通过Java代码实现的
synchronized是可以自动获取/释放锁的,但是ReentrantLock需要手动获取/释放锁
ReentrantLock还具有响应中断、超时等等待等特性
ReentrantLock可以实现公平锁,而synchronized只是非公平锁
63. CAS
CAS是项乐观锁技术(乐观锁还有版本号),当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”这其实和乐观锁的冲突检查+数据更新的原理是一样的
在JDK1.5 中新增java.util.concurrent(J.U.C)就是建立在CAS之上的。相对于对于synchronized这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。
64. CAS和ABA
CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的
两种解决方法:
- 版本号方法解决,每次执行数据修改的时候版本号加1,提交版本必须大于记录当前版本才能执行更新
- AtomicStampedReference类,跟原始的三个V,E,N值相比,多比较了引用,检查当前引用是否是预期引用
65. Java中UnSafe
Unsafe是CAS的核心类。因为Java无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM还是开了一个后门,JDK中有一个类Unsafe,它提供了硬件级别的原子操作。
Unsafe是Java中一个底层类,包含了很多基础的操作,比如数组操作、对象操作、内存操作、CAS操作、线程(park)操作、栅栏(Fence)操作,JUC包、一些三方框架都使用Unsafe类来保证并发安全。
Unsafe类在jdk 源码的多个类中用到,这个类的提供了一些绕开JVM的更底层功能,基于它的实现可以提高效率。但是,它是一把双刃剑:正如它的名字所预示的那样,它是Unsafe的,它所分配的内存需要手动free(不被GC回收)。
Unsafe类,提供了JNI某些功能的简单替代:确保高效性的同时,使事情变得更简单。 Unsafe类提供了硬件级别的原子操作,主要提供了以下功能:
- 通过Unsafe类可以分配内存,可以释放内存
- 可以定位对象某字段的内存位置,也可以修改对象的字段值,即使它是私有的
- 将线程进行挂起与恢复
- CAS操作
66. ReentrantLock如何实现可重入
ReentrantLock 内部自定义了同步器 Sync,其实就是加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程 ID 和当前请求的线程 ID 是否一样,一样就可重入了
67. atomic包
Java从JDK1.5开始提供了java.util.concurrent.atomic包,方便程序员在多线程环境下,无锁的进行原子操作。
Atomic包里的类基本都是使用Unsafe实现的包装类,核心操作是CAS原子操作; 在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段。Atomic包里的类基本都是使用Unsafe实现的包装类。
参考
来源:CSDN
作者:王星星的魔灯
链接:https://blog.csdn.net/coder_what/article/details/104111639