目录
JMM定义
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享 (本章用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local Variables),方 法定义参数(Java语言规范称之为Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影 响。 Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享 变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽 象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地 内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优 化
?从上图来看,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。 1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。 2)线程B到主内存中去读取线程A之前已更新过的共享变量。 下面通过下图)来说明这两个步骤
如上图所示,本地内存A和本地内存B由主内存中共享变量x的副本。假设初始时,这3个 内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存 A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内 存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时 线程B的本地内存的x值也变为了1。 从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要 经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供 内存可见性保证
这时候需要介绍下背景。有时候任务需要周期性地等待某些会造成计算延迟的事件的发生和完成。
一个例子是存储读取事件。在我们的机器中的CPU,这个处理器可以运行得很快。但是它必须要和其它的组件进行通信,组件有可能是在同一块主板的其它芯片,也可能是存储单元。你可能甚至还想读取一个远程的存储单元。这意味着你可能需要通过以太网或者WiFI进行网络通信,这就让事件的等待时间变得更长了。这就造成CPU虽然执行速度快,但计算资源可能浪费在了等待上。
另一个可以举的简单例子是:
计算X=Y+Z。这个大任务,可以分解成几个小任务,首先是把Y和Z从存储单元中读取出来,然后你才可以计算的Y+Z的和X,再然后,你要把这个计算结果写入到存储单元中。求和发出的add指令执行时间可能就仅仅只需要1个时钟周期, 但是单是内存访问读取Y和Z的操作,就可以花费超过100个时钟周期的时间。
所以现在大家或许可以发现问题了,我们可能会发出疑问,是否可以让处理器在一项任务的等待时期去执行一些更有意义的代码?比如当任务1在等待的时候,处理器去执行任务2,任务2在等待的时候,处理器就去执行任务3。在这个过程中的hiding latency,这个latency也可以被指为wait time latency。
所以回到我们最初的问题,并发在单核处理器的情况下依然可以极大地提升程序执行表现。
?
?在单核CPU上不存在可见性问题。?这是为什么呢?
因为在单核CPU上,无论创建了多少个线程,同一时刻只会有一个线程能够获取到CPU的资源来执行任务,即使这个单核的CPU已经添加了缓存。这些线程都是运行在同一个CPU上,操作的是同一个CPU的缓存,只要其中一个线程修改了共享变量的值,那另外的线程就一定能够访问到修改后的变量值。
?
并发是指一个处理器同时处理多个任务。
并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。
并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。
来个比喻:并发是一个人同时吃三个馒头,而并行是三个人同时吃三个馒头。
?
Java虚拟机规范中定义了一种Java内存 模型(Java Memory Model,即JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。Java内存模型的主要目标就是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的细节。
每个CPU对共享变量的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还是只存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值,这就是其中一种可见性问题。
还有一种可见性问题,CPU0和CPU1都读取了变量a到自己的副本中,CPU0对变量a 做了写操作并同步到了内存,但是CPU1在后面的操作中没有从内存更新变量值,而是直接使用了之前缓存的值,这样也会导致数据结果不正确。
?
在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是什么时候最新的值会被刷新至主内存中是不太确定的,这也就解释了为什么VolatileFoo中的Reader线程始终无法获取到init_value最新的变化。
·?使用关键字volatile,当一个变量被volatile关键字修饰时,对于共享资源的读操作会直接在主内存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中。
首先,Happens-Before是一种可见性模型,也就是说,在多线程环境下。
原本因为指令重排序的存在会导致数据的可见性问题,也就是A线程修改某个共享变量对B线程不可见。
因此,JMM通过Happens-Before关系向开发人员提供跨越线程的内存可见性保证。
如果一个操作的执行结果对另外一个操作可见,那么这两个操作之间必然存在Happens-Before管理。
其次,Happens-Before关系只是描述结果的可见性,并不表示指令执行的先后顺序,也就是说只要不对结果产生影响,仍然允许指令的重排序。
?
当正确的使用了同步,上面属性都会具有一个简单的特性:一个同步方法或者代码块中所做的修改对于使用了同一个锁的同步方法或代码块都具有原子性和可见性。同步方法或代码块之间的执行过程都会和代码指定的执行顺序保持一致。即使代码块内部指令也许是乱序执行的,也不会对使用了同步的其它线程造成任何影响。
当没有使用同步或者使用的不一致的时候,情况就会变得复杂。Java内存模型所提供的保障要比大多数开发人员所期望的弱,也远不及目前业界所实现的任意一款Java虚拟机。这样,开发人员就必须负起额外的义务去保证对象的一致性关系:对象间若有能被多个线程看到的某种恒定关系,所有依赖这种关系的线程就必须一直维持这种关系,而不仅仅由执行状态修改的线程来维持。
除了long型字段和double型字段外,java内存模型确保访问任意类型字段所对应的内存单元都是原子的。这包括引用其它对象的引用类型的字段。此外,volatile long 和volatile double也具有原子性 。(虽然java内存模型不保证non-volatile long 和 non-volatile double的原子性,当然它们在某些场合也具有原子性。)(译注:non-volatile long在64位JVM,OS,CPU下具有原子性)
当在一个表达式中使用一个non-long或者non-double型字段时,原子性可以确保你将获得这个字段的初始值或者某个线程对这个字段写入之后的值;但不会是两个或更多线程在同一时间对这个字段写入之后产生混乱的结果值(即原子性可以确保,获取到的结果值所对应的所有bit位,全部都是由单个线程写入的)。但是,如下面(译注:指可见性章节)将要看到的,原子性不能确保你获得的是任意线程写入之后的最新值。 因此,原子性保证通常对并发程序设计的影响很小。
而本质上呢,其实就是锁对象头中的Markword内容又要发生变化了。
下面先简单地描述 其膨胀的步骤:
同样,我们还是利用Java开发人员提供的一张图来描述此步骤:
分为如下几个层次。
1 了解理论,会用API开发功能,比如会用Redis的API缓存数据。
2 能根据项目的高并发需求,使用组件解决实际问题,比如会用Kafka组件实现高并发场景下的异步处理或消息缓存的流程,同时能根据实际需求,配置Kafka或dubbo等组件的参数。
3 使用组件实现高并发需求时,能解决遇到的实际问题。比如项目中遇到Netty或Dubbo方面的OOM问题,能通过查资料和查日志解决。
4 熟练掌握扩容、搭建集群和部署组件和业务模块的技术,能在一无所有的前提下,帮助项目组搭建一个能应对高并发的系统,同时能解决里面的问题。
其中对Java初级开发而言,最好需要掌握第一个层次的技能,对Java高级开发而言,需要掌握第二层次的技能,最好再要有一定的解决分布式组件问题的经验,即需要部分达到第三层次的标准。而Java架构师一定得达到第三层次的标准,至于第四层次的标注,是针对资深架构而言的。
讲到这里大家其实可以理解,平时在并发层面出现频率不少的锁、原子类或Synchronized等技术,其实是包含在Netty,Dubbo或Kafka等组件背后的原理或实现机制,学好了不能说没用,但也就是聊胜于无,或者说顶多面试时能用到,平时项目开发中未必会直接用到这些原理或底层算法。
举例来说吧,比如要实现高并发场景下的分布式锁,大多数项目的做法是用Seata等组件,通过配置和API实现分布式锁里的提交或回滚等功能,而绝不是自己根据二阶段提交等算法,自己设计开发一套实现分布式锁的组件。
将synchronized修饰在普通同步方法,那么该锁的作用域是在当前实例对象范围内,也就是说对于 SyncDemosd=newSyncDemo();这一个实例对象sd来说,多个线程访问access方法会有锁的限制。如果access已经有线程持有了锁,那这个线程会独占锁,直到锁释放完毕之前,其他线程都会被阻塞
public SyncDemo{
Object lock =new Object();
//形式1
public synchronized void access(){
//
}
//形式2,作用域等同于形式1
public void access1(){
synchronized(lock){
//
}
}
//形式3,作用域等同于前面两种
public void access2(){
synchronized(this){
//
}
}
}
修饰静态同步方法或者静态对象、类,那么这个锁的作用范围是类级别。举个简单的例子,
SyncDemo sd=SyncDemo(); SyncDemo sd2=new SyncDemo();}
两个不同的实例sd和sd2, 如果sd这个实例访问access方法并且成功持有了锁,那么sd2这个对象如果同样来访问access方法,那么它必须要等待sd这个对象的锁释放以后,sd2这个对象的线程才能访问该方法,这就是类锁;也就是说类锁就相当于全局锁的概念,作用范围是类级别。
这里抛一个小问题,大家看看能不能回答,如果不能也没关系,后面会讲解;问题是如果sd先访问access获得了锁,sd2对象的线程再访问access1方法,那么它会被阻塞吗?
public SyncDemo{ static Object lock=new Object(); //形式1 public synchronized static void access(){ // } //形式2等同于形式1 public void access1(){ synchronized(lock){ // } } //形式3等同于前面两种 public void access2(){ synchronzied(SyncDemo.class){ // } } }
普通同步方法,锁是当前实例对象。比如:
public synchronized void doLongTimeTaskC() {}
无锁——》偏向锁——》轻量级锁——》重量级锁——》GC锁
可重入的含义:
指的是同一个线程获得锁之后,再不释放锁的情况下,可以直接再次获取到该锁
@Slf4j
public class SynReentrantDemo {
public static void main(String[] args) {
Runnable sellTicket = new Runnable() {
@Override
public void run() {
synchronized (this) {
String name = Thread.currentThread().getName();
log.info("我是run,抢到锁的是{}", name);
test01();
} //正常来说走出临界区(这个括号)才会释放锁,但是再没走出之前,又进入test01,
//而test01需要和本方法一样的锁
//如果不可重入的话,就将出现死锁了-->即test01方法等着释放锁,而run方法又不会释放锁
//因此synchronized只有可以在不释放run方法的锁的情况下,又再次获得该锁才不会有问题
}
public void test01() {
synchronized (this) {
String name = Thread.currentThread().getName();
log.info("我是test01,抢到锁的是{}", name);
}
}
};
new Thread(sellTicket).start();
new Thread(sellTicket).start();
}
}
首先应该知道synchronized锁的并不是同步代码块,而是锁对象关联的一个monitor对象(在java中每一个对象都会关联一个monitor对象),而这个对象里有一个变量叫recursions — 中文是递归的意思(我想大概是因为递归的时候发生可重入的几率应该是最大的,所以才用这个当变量名的吧),其实可以将它简单理解为一个计数器。
以上面的栗子为例:
(1)当线程1抢到run方法的执行权即抢到锁时,这个recursions的值就变为了1;
(2)线程1接着运行并进入test01方法后,发现还是线程1且还是要this这把锁,就将recursions的值再+1;
(3)当线程1,执行完test01方法时,recursions的值又-1
(4)执行完run方法时recursions的值又-1,就变为了0,也就是表示线程1已经释放了this锁。
(5)之后其他线程就可以继续抢this锁了。
?
非公平锁的 lock 方法:
static final class NonfairSync extends Sync {
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
// AbstractQueuedSynchronizer.acquire(int arg)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 这里没有对阻塞队列进行判断
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
公平锁和非公平锁只有两处不同:
公平锁和非公平锁就这两点区别,如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒。
相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。
SynchronizedQueue:
SynchronizedQueue 是一个比较特殊的队列,它没有存储功能,它的功能就是维护一组线程,其中每个插入操作必须等待另一个线程的移除操作,同样任何一个移除操作都等待另一个线程的插入操作。因此此队列内部其 实没有任何一个元素,或者说容量是0,严格说并不是一种容器。由于队列没有容量,因此不能调用 peek 操作,因为只有移除元素时才有元素。
举个例子:
喝酒的时候,?先把酒倒入酒盅,然后再倒入酒杯,这就是正常的队列?。
喝酒的时候,?把酒直接倒入酒杯,这就是 SynchronizedQueue?。
这个例子应该很清晰易懂了,它的好处就是可以直接传递,省去了一个第三方传递的过程。
ReentrantLock 意为?可重入锁?,说起 ReentrantLock 就不得不说 AQS ,因为其?底层就是使用 AQS?去实现的。
ReentrantLock有?两种模式?,一种是公平锁,一种是非公平锁。
AQS 在锁的获取时,并不一定只有一个线程才能持有这个锁,所以此时有了?独占模式和共享模式?的区别,我们本篇文章中的?ReentrantLock?使用的就是独占模式,在多线程的情况下只会有一个线程获取锁。
可重入锁就是当前持有锁的线程能够多次获取该锁,无需等待
AQS实现一个可重入锁
package com;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
public class MyLock implements Lock {
private Helper helper = new Helper();
private class Helper extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
// 第一个线程进来,可以获取锁
// 第二个线程进来,无法获取锁,返回false
Thread thread = Thread.currentThread();
// 判断是否为第一个线程进来
int state = getState();
if (state == 0) {
if (compareAndSetState(0, arg)) {// 如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值
// 设置当前线程
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
} else if(getExclusiveOwnerThread() == thread) { // 允许重入锁,当前线程和当前保存的线程是同一个线程
setState(state + 1);
return true;
}
return false;
}
/***
* 释放锁
此方法总是由正在执行释放的线程调用。
*/
@Override
protected boolean tryRelease(int arg) {
// 锁的获取和释放肯定是一一对应的,那么调用此方法的线程一定是当前线程
if (Thread.currentThread() != getExclusiveOwnerThread()) {
throw new RuntimeException();
}
boolean flag = false;
int state = getState() -arg;
if (state == 0) {// 当前锁的状态正确
setExclusiveOwnerThread(null);
flag = true;
}
setState(state);
return flag;
}
protected Condition newCondition() {
return new ConditionObject();
}
}
@Override
public void lock() {
// 独占锁
helper.acquire(1);
}
@Override
public void lockInterruptibly() throws InterruptedException {
// 可中断
helper.acquireInterruptibly(1);
}
@Override
public boolean tryLock() {
return helper.tryAcquire(1);
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return helper.tryAcquireNanos(1, unit.toNanos(time));
}
@Override
public void unlock() {
helper.release(1);
}
@Override
public Condition newCondition() {
return helper.newCondition();
}
}
在Java并发编程中,每个Java堆中的对象在“出生”的时刻都会“伴生”一个监视器对象,而每个Java对象都会有一组监视器方法:wait()
、notify()
以及notifyAll()
。我们可以通过这些方法实现Java多线程之间的协作和通信,也就是等待唤醒机制,如常见的生产者-消费者模型。但是关于Java对象的这组监视器方法我们在使用过程中,是需要配合synchronized关键字才能使用,因为实际上Java对象的等待唤醒机制是基于monitor监视器对象实现的。与synchronized关键字的等待唤醒机制相比,Condition则更为灵活,因为synchronized的notify()
只能随机唤醒等待锁的一个线程,而Condition则可以更加细粒度的精准唤醒等待锁的某个线程。与synchronized的等待唤醒机制不同的是,在monitor监视器模型上,一个对象拥有一个同步队列和一个等待队列,而AQS中一个锁对象拥有一个同步队列和多个等待队列。对象监视器Monitor锁实现原理如下:
就像生命一样,线程也有从出生到死亡的过程,这个过程就是线程的生命周期,在java中,线程的生命周期共有6种状态,分别是:
1.初始(NEW):
新创建了一个线程对象,但还没有调用start()方法。
运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
2.线程对象创建后
,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
3.阻塞(BLOCKED)
:表示线程阻塞于锁。
4.等待(WAITING)
:进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
5.超时等待(TIMED_WAITING)
:该状态不同于WAITING,它可以在指定的时间后自行返回。
6.终止(TERMINATED)
:表示该线程已经执行完毕。
如果想要确定线程当前的状态,可以通过 getState() 方法,并且线程在任何时刻只可能处于 1 种状态。
?
ThreadPoolExecutor是JDK中的线程池实现,这个类实现了一个线程池需要的各个方法,它提供了任务提交、线程管理、监控等方法。
下面是ThreadPoolExecutor类的构造方法源码,其他创建线程池的方法最终都会导向这个构造方法,共有7个参数:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。
?
单线程线程池只创建一个线程来执行任务。当有多个任务到来时,单线程线程池会按照先进先出(FIFO)的顺序依次执行任务。这种线程池适用于任务量较小且需要保证任务顺序的情况。
固定大小线程池会预先创建一定数量的线程,并保持这些线程始终存在。当有任务到来时,固定大小线程池会从已存在的线程中选择一个来执行任务。这种线程池适用于任务量较大且任务之间相互独立的情况。
除了以上三种常见的线程池类型外,Java还允许我们自定义线程池。我们可以根据实际需求来定义线程池的参数,如核心线程数、最大线程数、任务队列等。这种自定义的线程池可以更好地满足特定场景下的需求。
? ? ? ? 死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,这些线程将无法继续执行下去。
死锁的发生通常是由于以下四个条件同时满足所致:
- 互斥条件:一个资源每次只能被一个线程使用。
- 请求与保持条件:一个线程在申请资源的同时保持对已有资源的占有。
- 不剥夺条件:线程已获得的资源在未使用完之前,不能被其他线程强行剥夺,只能由该线程自己释放。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
? ? ? ? 当多个线程同时请求对方持有的资源时,就会发生死锁。例如,线程A持有资源X,但需要资源Y,而线程B持有资源Y,但需要资源X。这种情况下,线程A和线程B将互相等待对方释放资源。
? ? ? ? 嵌套锁的循环等待是指线程在持有一个锁的同时,又尝试获取另一个锁,而其他线程也在相反的顺序尝试获取这两个锁。这种情况下,可能会导致线程之间发生死锁。
? ? ? ? 当多个线程按照相同的顺序请求资源时,也可能会发生死锁。例如,线程A先请求资源X,再请求资源Y,而线程B也按照相同的顺序请求这两个资源。如果线程A和线程B同时持有一个资源,并且等待对方释放另一个资源,就会发生死锁。
? ? ? ? jstack命令可以用来查看Java进程中的线程堆栈信息。通过查看线程堆栈信息,可以判断是否存在死锁。如果存在死锁,可以通过分析线程堆栈信息找出造成死锁的原因。
? ? ? ? jconsole是Java自带的一款监控和管理工具,可以用来监控Java应用程序的性能和内存使用情况。通过查看线程的状态和堆栈信息,可以判断是否存在死锁。
? ? ? ? VisualVM是一款功能强大的Java性能分析工具,它提供了一系列的插件和工具,可以用来监控Java应用程序的性能和线程状态。通过查看线程状态和堆栈信息,可以发现死锁的存在。
? ? ? ? 为了避免嵌套锁的循环等待,可以按照相同的顺序获取锁。例如,如果线程A需要先获取锁X,再获取锁Y,那么线程B也应该按照相同的顺序获取这两个锁。
? ? ? ? 在获取锁的时候,可以设置一个超时时间。如果在指定的时间内无法获取到锁,就放弃获取锁,并执行相应的处理逻辑。这样可以避免线程无限等待锁的释放。
? ? ? ? 资源分配图是一种用来描述资源的分配和请求关系的图形表示方法。通过绘制资源分配图,可以直观地看出哪些资源被哪些线程请求和占用,从而判断是否存在死锁,并采取相应的措施解决死锁问题。
?
?死锁是Java并发编程中一个常见而又棘手的问题。本文介绍了死锁的定义和原因,以及常见的死锁场景。为了解决死锁问题,可以使用jstack命令、jconsole和VisualVM等工具进行死锁排查。此外,还提供了一些常见的解决方案,如避免嵌套锁的循环等待、使用带超时的锁和资源分配图等。通过合理地选择和使用这些解决方案,可以有效地排查和解决死锁问题,提高系统的稳定性和可靠性。
?