Java并发编程面试题
并发与并行
并发与并行的区别是什么
并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象
如果系统只有一个 CPU,则它根本不可能真正同时进行一个以上的线程,它只能把 CPU 运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态.这种方式我们称之为并发(Concurrent)。
当系统有一个以上 CPU 时,则线程的操作有可能非并发。当一个 CPU 执行一个线程时,另一个 CPU 可以执行另一个线程,两个线程互不抢占 CPU 资源,可以同时进行,这种方式我们称之为并行(Parallel)。
线程
线程的状态
线程是一个动态执行的过程,它有一个从产生到死亡的过程,共五种状态:新建(newThread)、就绪(runnable)、运行(running)、死亡(dead)、堵塞(blocked)
创建线程的多种方式
继承Thread类实现多线程
覆写Runnable()接口实现多线程,而后同样覆写run()。
覆写Callable接口实现多线程(JDK1.5):核心方法叫call()方法,有返回值
通过线程池启动多线程
FixThreadPool(int n); 固定大小的线程池
SingleThreadPoolExecutor :单线程池
CachedThreadPool(); 缓存线程池
继承Thread和实现Runnable接口的区别:
a.实现Runnable接口避免多继承局限;
b.实现Runable接口可以实现资源共享
什么是守护线程?
只要当前JVM实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。
Daemon的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器)
Thread daemonTread = new Thread();
daemonThread.setDaemon(true);
1
2
线程和进程的区别是什么?
根本区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
所处环境:在操作系统中能同时运行多个进程(程序);而在同一个进程(程序)中有多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)
内存分配:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源。
包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
线程、进程、协程的区别是什么?
进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。
线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是这样的)。
协程和线程一样共享堆,不共享栈,协程由程序员在协程的代码里显示调度。
一个应用程序一般对应一个进程,一个进程一般有一个主线程,还有若干个辅助线程,线程之间是平行运行的,在线程里面可以开启协程,让程序在特定的时间内运行。
协程和线程的区别是:协程避免了无意义的调度,由此可以提高性能,但也因此,程序员必须自己承担调度的责任,同时,协程也失去了标准线程使用多CPU的能力。
线程池
线程池有什么作用?
提高效率:创建好一定数量的线程放在池中,等需要使用的时候就从池中拿一个,这要比需要的时候创建一个线程对象要快的多。
方便管理:可以编写线程池管理代码对池中的线程同一进行管理,比如说启动时有该程序创建100个线程,每当有请求的时候,就分配一个线程去工作,如果刚好并发有101个请求,那多出的这一个请求可以排队等候,避免因无休止的创建线程导致系统崩溃。
说说几种常见的线程池及使用场景
1、newSingleThreadExecutor
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
2、newFixedThreadPool
创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
3、newCachedThreadPool
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
4、newScheduledThreadPool
创建一个定长线程池,支持定时及周期性任务执行。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:
1)newFixedThreadPool和newSingleThreadExecutor:
主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
2)newCachedThreadPool和newScheduledThreadPool:
主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
线程池都有哪几种工作队列
ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列
SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
线程池中的几种重要的参数及流程说明
corePoolSize:核心池的大小,在创建了线程池后,默认情况下,线程池中并没有任何线程,而是等待有任务到来才创建线程去执行任务,除非调用了prestartAllCoreThreads()或者prestartCoreThread()方法,从这2个方法的名字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建corePoolSize个线程或者一个线程。默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;
maximumPoolSize:线程池最大线程数,它表示在线程池中最多能创建多少个线程;
keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。默认情况下,只有当线程池中的线程数大于corePoolSize时,keepAliveTime才会起作用,直到线程池中的线程数不大于corePoolSize,即当线程池中的线程数大于corePoolSize时,如果一个线程空闲的时间达到keepAliveTime,则会终止,直到线程池中的线程数不超过corePoolSize。但是如果调用了allowCoreThreadTimeOut(boolean)方法,在线程池中的线程数不大于corePoolSize时,keepAliveTime参数也会起作用,直到线程池中的线程数为0;
unit:参数keepAliveTime的时间单位
workQueue:一个阻塞队列,用来存储等待执行的任务,这个参数有以下几种选择:
ArrayBlockingQueue
LinkedBlockingQueue
SynchronousQueue
PriorityBlockingQueue
hreadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程做些更有意义的事情,比如设置daemon和优先级等
handler:表示当拒绝处理任务时的策略,有以下四种取值:
1、AbortPolicy:直接抛出异常。
2、CallerRunsPolicy:只用调用者所在线程来运行任务。
3、DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
4、DiscardPolicy:不处理,丢弃掉。
5、根据应用场景需要实现RejectedExecutionHandler接口自定义策略。如记录日志或持久化不能处理的任务
怎么理解无界队列和有界队列
有界队列
初始的poolSize < corePoolSize,提交的runnable任务,会直接做为new一个Thread的参数,立马执行。
当提交的任务数超过了corePoolSize,会将当前的runable提交到一个block queue中。
有界队列满了之后,如果poolSize < maximumPoolsize时,会尝试new 一个Thread的进行救急处理,立马执行对应的runnable任务。
如果3中也无法处理了,就会走到第四步执行reject操作。
无界队列
与有界队列相比,除非系统资源耗尽,否则无界的任务队列不存在任务入队失败的情况。
当有新的任务到来,系统的线程数小于corePoolSize时,则新建线程执行任务。当达到corePoolSize后,就不会继续增加,若后续仍有新的任务加入,而没有空闲的线程资源,则任务直接进入队列等待。
若任务创建和处理的速度差异很大,无界队列会保持快速增长,直到耗尽系统内存。
当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略。
如何合理配置线程池的大小
大家认为线程池的大小经验值应该这样设置:(其中N为CPU的个数)
如果是CPU密集型应用,则线程池大小设置为N+1
如果是IO密集型应用,则线程池大小设置为2N+1
线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。
第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。
submit()和execute()
JDK5往后,任务分两类:一类是实现了Runnable接口的类,一类是实现了Callable接口的类。两者都可以被ExecutorService执行,它们的区别是:
execute(Runnable x) 没有返回值。可以执行任务,但无法判断任务是否成功完成。——实现Runnable接口
submit(Runnable x) 返回一个future。可以用这个future来判断任务是否成功完成。——实现Callable接口
线程池原理
线程池提供了两个钩子(beforeExecute,afterExecute)给我们,我们继承线程池,在执行任务前后做一些事情。
线程池由两个核心数据结构组成:
1)线程集合(workers):存放执行任务的线程,是一个HashSet;
2)任务等待队列(workQueue):存放等待线程池调度执行的任务,是一个阻塞式队列BlockingQueue;
任务执行流程
1)线程池中线程数量小于corePoolSize,此时任务不会进等待队列,线程池直接创建一个线程Worker执行提交的任务;
2)线程池中线程数量不小于corePoolSize并且等待队列未满,任务直接添加到等待队列,等待线程池调度执行;
3)线程池中线程数量不小于corePoolSize但是等待队列已满且线程数量小于maximumPoolSize,线程池会进行扩容新创建一个线程Worker执行提交的任务,新创建的Worker会被添加到线程集合workers中;
4)等待队列已满并且线程数量已达到maximumPoolSize,这种情况下线程池无法继续执行任务会拒绝任务,执行一个指定的拒接策略。
JDK内置的拒绝策略主要有下面几种:
1)调用线程执行(CallerRunsPolicy):任务被线程池拒绝后,任务会被调用线程执行;
2)终止执行(AbortPolicy):任务被拒绝时,抛出RejectedExecutionException异常报错
3)丢弃任务(DiscardPolicy):任务被直接丢弃,不会抛异常报错;
4)丢失老任务(DiscardOldestPolicy):把等待队列中最老的任务删除,删除后重新提交当前任务。
线程安全
死锁产生的4个必要条件?
互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
怎么预防死锁?
资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
总结:
1、以确定的顺序获得锁
2、超时放弃
Java各种锁
CAS
CAS操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
CAS的缺点:
CPU开销较大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
ABA问题:
CAS可以有效的提升并发的效率,但同时也会引入ABA问题。
比如线程1从内存X中取出A,这时候另一个线程2也从内存X中取出A,并且线程2进行了一些操作将内存X中的值变成了B,然后线程2又将内存X中的数据变成A,这时候线程1进行CAS操作发现内存X中仍然是A,然后线程1操作成功。虽然线程1的CAS操作成功,但是整个过程就是有问题的。
比如链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。
所以JAVA中提供了AtomicStampedReference/AtomicMarkableReference来处理会发生ABA问题的场景,主要是在对象中额外再增加一个标记来标识对象是否有过变更。
乐观锁常见的实现方式
版本号机制:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
CAS算法:即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数:需要读写的内存值 V;进行比较的值 A;拟写入的新值 B。当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
自旋锁和适应性自旋锁
Why:切换线程的开销大,在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
What:为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
How:
当前线程竞争锁失败时,打算阻塞自己
不直接阻塞自己,而是自旋一会(空等待,比如一个空的有限for循环
在自旋的同时重新竞争锁
如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己
如果在自旋的时间内,锁就被旧owner释放了,那么当前线程就不需要阻塞自己(也不需要在未来锁释放时恢复),减少了一次线程切换。
自旋锁的缺点:自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
实现原理:自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
适应性自旋锁:
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁是指锁的状态,专门针对synchronized的。偏斜锁、轻量级锁、重量级锁的代码实现,并不在核心类库部分,而是在JVM的代码中。
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,以Hotspot虚拟机为例,对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
四种锁状态对应的的Mark Word内容:
无锁:
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。CAS原理及应用即是无锁的实现。
偏向锁:
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁:
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
公平锁和非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
可重入锁和非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。NonReentrantLock是非可重入锁。
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1执行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2执行...");
}
}
1
2
3
4
5
6
7
8
9
10
类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
独享锁和共享锁
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁都是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
JMM
什么是JMM
JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。
屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。
目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
原子性、可见性、有序性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。基本数据类型的访问都具备原子性,例外就是 long 和 double,虚拟机将没有被 volatile 修饰的 64 位
数据操作划分为两次 32 位操作。
可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。除了 volatile 外,synchronized 和 final 也可以保证可见性。
有序性:即程序执行的顺序按照代码的先后顺序执行。Java 提供 volatile 和 synchronized 保证有序性
as-if-serial
as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。所以编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
happens-before
Why?
因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
在程序运行过程中,所有的变更会先在寄存器或本地cache中完成,然后才会被拷贝到主存以跨越内存栅栏(本地或工作内存到主存之间的拷贝动作),此种跨越序列或顺序称为happens-before。happens-before本质是顺序,重点是跨越内存栅栏。
What?
happens-before 在Java内存模型中意味着:前一个操作的结果可以被后续的操作获取。例如前面一个操作把变量a赋值为1,那后面的操作肯定能知道a已经变成了1。
程序次序规则:一个线程内写在前面的操作先行发生于后面的。
管程锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。
volatile 规则:对 volatile 变量的写操作先行发生于后面的读操作。
线程启动规则:线程的 start 方法先行发生于线程的每个动作。
线程终止规则:线程中所有操作先行发生于对线程的终止检测。
对象终结规则:对象的初始化先行发生于 finalize 方法。
as-if-serial 和 happens-before 的区别
as-if-serial 保证单线程程序的执行结果不变,happens-before 保证正确同步的多线程程序的执行结果不变。
这两种语义的目的都是为了在不改变程序执行结果的前提下尽可能提高程序执行并行度。
指令重排序
为了提高性能,编译器和处理器通常会对指令进行重排序,重排序指从源代码到指令序列的重排序,分为三种:
① 编译器优化的重排序,编译器在不改变单线程程序语义的前提下可以重排语句的执行顺序。
② 指令级并行的重排序,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
③内存系统的重排序。
volatile
三个特性:
可见性:即当一个线程修改了声明为volatile变量的值,新值对于其他要读该变量的线程来说是立即可见的。而普通变量是不能做到这一点的,普通变量的值在线程间传递需要通过主内存来完成。
有序性:volatile变量的所谓有序性也就是被声明为volatile的变量的临界区代码的执行是有顺序的,即禁止指令重排序。
受限原子性:这里volatile变量的原子性与synchronized的原子性是不同的,synchronized的原子性是指只要声明为synchronized的方法或代码块儿在执行上就是原子操作的。而volatile是不修饰方法或代码块儿的,它用来修饰变量,对于单个volatile变量的读/写操作都具有原子性,但类似于volatile++这种复合操作不具有原子性。所以volatile的原子性是受限制的。并且在多线程环境中,volatile并不能保证原子性。
volatile写-读的内存语义:volatile写的内存语义:当写线程写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:当读线程读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存读取共享变量。
实现原理:
volatile可见性的实现就是借助了CPU的lock指令,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则:
写volatile时处理器会将缓存写回到主内存。
一个处理器的缓存写回到内存会导致其他处理器的缓存失效。
volatile有序性的保证就是通过禁止指令重排序来实现的。
synchronized
synchronized是如何实现的
synchronized是由一对儿monitorentry/monitorexit指令实现的,Monitor对象是同步的基本实现单元。
在Java 6之前,Monitor的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
现代的(Oracle)JDK中,JVM对此进行了大刀阔斧地改进,提供了三种不同的Monitor实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。
synchronized和lock的区别
来源: lock是一个接口,而synchronized是java的一个关键字,synchronized是内置的语言实现;
异常是否释放锁: synchronized在发生异常时候会自动释放占有的锁,因此不会出现死锁;而lock发生异常时候,不会主动释放占有的锁,必须手动unlock来释放锁,可能引起死锁的发生。(所以最好将同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生。)
是否响应中断:lock等待锁过程中可以用interrupt来中断等待,而synchronized只能等待锁的释放,不能响应中断;
是否知道获取锁:Lock可以通过trylock来知道有没有获取锁,而synchronized不能;
Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。
synchronized使用Object对象本身的wait 、notify、notifyAll调度机制,而Lock可以使用Condition进行线程之间的调度
synchronized和volatile的区别
区别
volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
JUC
AQS是什么
AQS 队列同步器是用来构建锁或其他同步组件的基础框架,它使用一个 volatile int state变量作为共享资源,如果线程获取资源失败,则进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。
同步器面向锁的实现者,简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与唤醒等底层操作。
AQS 两种模式
独占模式表示锁只会被一个线程占用,其他线程必须等到持有锁的线程释放锁后才能获取锁,同一时间只能有一个线程获取到锁。
共享模式表示多个线程获取同一个锁有可能成功,ReadLock 就采用共享模式。
独占模式通过 acquire 和 release 方法获取和释放锁,共享模式通过 acquireShared 和 releaseShared方法获取和释放锁。
原子类
JDK 5 提供了 java.util.concurrent.atomic 包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。到 JDK 8 该包共有17个类,依据作用分为四种:原子更新基本类型类、原子更新数组类、原子更新引用类以及原子更新字段类,atomic 包里的类基本都是使用 Unsafe实现的包装类。
AtomicIntger 实现原子更新的原理
getAndIncrement 以原子方式将当前的值加 1,首先在 for 死循环中取得 AtomicInteger 里存储的数值,第二步对 AtomicInteger 当前的值加 1 ,第三步调用 compareAndSet 方法进行原子更新,先检查当前数值是否等于 expect,如果等于则说明当前值没有被其他线程修改,则将值更新为 next,否则会更新失败返回 false,程序会进入 for 循环重新进行 compareAndSet 操作。
CountDownLatch
CountDownLatch 是基于执行时间的同步类,允许一个或多个线程等待其他线程完成操作,构造方法接收一个 int 参数作为计数器,如果要等待 n 个点就传入 n。每次调用 countDown 方法时计数器减 1,await 方法会阻塞当前线程直到计数器变为0,由于 countDown 方法可用在任何地方,所以 n 个点既可以是 n 个线程也可以是一个线程里的 n 个执行步骤。
CyclicBarrier
循环屏障是基于同步到达某个点的信号量触发机制,作用是让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障才会解除。构造方法中的参数表示拦截线程数量,每个线程调用 await 方法告诉CyclicBarrier 自己已到达屏障,然后被阻塞。还支持在构造方法中传入一个 Runnable 任务,当线程到达屏障时会优先执行该任务。适用于多线程计算数据,最后合并计算结果的应用场景。
CountDownLacth 的计数器只能用一次,而 CyclicBarrier 的计数器可使用 reset 方法重置,所以CyclicBarrier 能处理更为复杂的业务场景,例如计算错误时可用重置计数器重新计算。
Semaphore
信号量用来控制同时访问特定资源的线程数量,通过协调各个线程以保证合理使用公共资源。信号量可以用于流量控制,特别是公共资源有限的应用场景,比如数据库连接。
Semaphore 的构造方法参数接收一个 int 值,表示可用的许可数量即最大并发数。使用 acquire 方法获得一个许可证,使用 release 方法归还许可,还可以用 tryAcquire 尝试获得许可。
Exchanger
交换者是用于线程间协作的工具类,用于进行线程间的数据交换。它提供一个同步点,在这个同步点两个线程可以交换彼此的数据。
两个线程通过 exchange 方法交换数据,第一个线程执行 exchange 方法后会阻塞等待第二个线程执行该方法,当两个线程都到达同步点时这两个线程就可以交换数据,将本线程生产出的数据传递给对方。
应用场景包括遗传算法、校对工作等。
其它
sleep wait
属于不同的两个类,sleep()方法是线程类(Thread)的静态方法,wait()方法是Object类里的方法。
sleep()方法不会释放锁,wait()方法释放对象锁。
sleep()方法可以在任何地方使用,wait()方法则只能在同步方法或同步块中使用。
sleep()使线程进入阻塞状态(线程睡眠),wait()方法使线程进入等待队列(线程挂起),也就是阻塞类别不同。
wait notify notifAll
wait()、notify/notifyAll() 方法是Object的本地final方法,无法被重写。
wait() 需要被try catch包围,以便发生异常中断也可以使wait等待的线程唤醒。
notify 和wait 的顺序不能错,如果A线程先执行notify方法,B线程再执行wait方法,那么B线程是无法被唤醒的。
notify方法只唤醒一个等待线程并使该线程开始执行。notifyAll 会唤醒所有等待线程。
一般在synchronized 同步代码块里使用 wait()、notify/notifyAll() 方法。
由于 wait()、notify/notifyAll() 在synchronized 代码块执行,说明当前线程一定是获取了锁的。当线程执行wait()方法时候,会释放当前的锁,然后让出CPU,进入等待状态。
只有当 notify/notifyAll() 被执行时候,才会唤醒一个或多个正处于等待状态的线程,然后继续往下执行,直到执行完synchronized 代码块的代码或是中途遇到wait() ,再次释放锁。
也就是说,notify/notifyAll() 的执行只是唤醒沉睡的线程,而不会立即释放锁,锁的释放要看代码块的具体执行情况。所以在编程中,尽量在使用了notify/notifyAll() 后立即退出临界区,以唤醒其他线程让其获得锁
ThreadLocal
ThreadLocal提供了线程内存储变量的能力,这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。通过get和set方法就可以得到当前线程对应的值。ThreadLocal相当于维护了一个map,key就是当前的线程,value就是需要存储的对象。实际上是ThreadLocal的静态内部类ThreadLocalMap为每个Thread都维护了一个数组table,ThreadLocal确定了一个数组下标,而这个下标就是value存储的对应位置。
Thread类中有一个私有的成员变量threadLocals,Thread并没有提供成员变量threadLocals的设置与访问的方法,ThreadLocal是线程Thread中属性threadLocals的管理者。对于ThreadLocal的get,
set,remove的操作结果都是针对当前线程Thread实例的threadLocals存,取,删除操作。
存在的问题
线程复用会产生脏数据,由于线程池会重用 Thread 对象,因此与 Thread 绑定的ThreadLocal 也会被重用。如果没有调用remove 清理与线程相关的 ThreadLocal 信息,那么假如下一个线程没有调用 set设置初始值就可能 get到重用的线程信息。
ThreadLocal 还存在内存泄漏的问题,由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放。因此需要及时调用 remove 方法进行清理操作。