JAVA并发原理笔记

  2020-5-4 


《JAVA并发编程的艺术》笔记,主要记录我以前理解不够的地方,并不是重点笔记

JAVA并发机制底层原理

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。

如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整 程序的并发度

如果一个字段被声明成volatile,Java线程内存 模型确保所有线程看到这个变量的值是一致的。

Lock前缀的指令在多核处理器下会引发了两件事情
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

为了保证各个处理器的缓存是一致的,就会实现缓存一 致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当 处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状 态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存 里。

————

从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对 象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有 详细说明。但是,方法的同步同样可以使用这两个指令来实现。(这里说的是重量级synchonized使用monitor对象,monitor锁底层是的操作系统mutex互斥信号量;非重量级不会用这个monitor)
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结 束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

synchronized用的锁是存在Java对象头里的一个markword字段(锁实质)
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位(4种锁状态:无,偏,轻,重)

当一个线程访问同步块并 获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出 同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否 存储着指向当前线程的偏向锁。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时(其它锁cas失败后修改Mark word撤销偏向锁), 持有偏向锁的线程才会释放锁

轻量级锁适用无实质竞争或少量竞争的并发(每个线程cas基本都成功),不需要加锁,这样就没有线程切换问题也没有内核态用户态切换问题,开销少
当cas大量失败,就会膨胀成重量级(太多cas自旋非常吃cpu)

————
cpu实现原子操作:总线锁(锁总线,完全禁止其它cpu访问ram)/缓存锁(锁缓存)
————
同步是指程序中用于控制不同线程间操作发生相对顺序的机制

JAVA内存模型:JMM

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享 变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽 象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地 内存(Local Memory)threadlocal?,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优 化。
线程A与线程B之间要通信的话,必须要经历下面2个步骤。
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。 2)线程B到主内存中去读取线程A之前已更新过的共享变量。
JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供 内存可见性保证。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:编译器优化级,cpu指令流水级,内存系统级;这些重排序可能会导致多线程程序 出现内存可见性问题 ;JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证(如写后读,du后写,写后写都不可重排)
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁 止特定类型的处理器重排序。
在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关 系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。一个happens-before规则对应于一个或多个编译器和处理器重排序规则。
JMM提供了happens-before保证:如果程序是正确同步(即需要程序员自己正确设置同步锁)的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程 序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(顺序一致性:a~一个线程中的所有操作必须按照程序的顺序来执行。且b~(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内 存模型中,每个操作都必须原子执行且立刻对所有线程可见。)(只是和它结果相同,如果真本体顺序一致模型了,那就是完全禁止重排序,少了很多编译器和指令优化)(可见对应不确定,意思是另一个线程的执行结果对这个线程是确定的)
如果A happens-before B,那么Java内存模型将向程序员保证——1.A操作的结果将对B可见, 2.且A的执行顺序排在B之前(重排序并不一定要求a先于b执行!只要结果与a先于b执行且a的结果对b可见这个haappens-before关系一致即可!)
JMM只根据happens-before规则禁止了会改变程序运行结果的重排序,不改变的不禁止
JMM其实是在遵 循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序) 编译器和处理器怎么优化都行。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为Java程序员呈现 了一个一致的内存模型。
对于未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取 到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。

————
volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
为了实现volatile内存语义,JMM 会通过在指令中插入内存屏障来禁止特定类型的重排序(见下)(不同的vloatile执行环境,禁止重排序事项也不同,它并不是禁止所有重排序,而只是禁止部分重排序)

也就是说:

1线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
2线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
3线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过 主内存向线程B发送消息。

————
释放锁内存语义:当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
获取锁内存语义:当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。

也就是说:
1线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
2线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。
3线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发 送消息。

并发编程基础

ReentrantLock是aqs框架的具体实现,其中用到了volatile和cas

0

更新变量都是靠cas,cas更新是原子操作
————
中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行 了中断操作。中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的interrupt() 方法对其进行中断操作。
————

等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B 调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而 执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的 关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
使用wait()、notify()和notifyAll()时需要先对调用对象加锁。
notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或 notifAll()的线程释放锁之后,等待线程才有机会从wait()返回。
wait时候就已经释放锁了!然后就等通知了,等到通知之后,再重新获取到锁继续执行!
流程:WaitThread首先获取了对象的锁,然后调用对象的wait()方法,从而放弃了锁 并进入了对象的等待队列WaitQueue中,进入等待状态。由于WaitThread释放了对象的锁,NotifyThread随后获取了对象的锁,并调用对象的notify()方法,将WaitThread从WaitQueue移到 SynchronizedQueue中,此时WaitThread的状态变为阻塞状态。NotifyThread释放了锁之后, WaitThread再次获取到锁并从wait()方法返回继续执行。

等待/通知机制依托于同步机制,其目的就是确保等待线程从wait()方法返回时能够感知到通知线程对变量做出的修改。
————
可以使用管道来进行线程数据传输
PipedOutputStream、PipedInputStream、PipedReader和PipedWriter
PipedWriter out = new PipedWriter();
PipedReader in = new PipedReader();
out.connect(in);
// 将输出流和输入流进行连接,否则在使用时会抛出IOException out.connect(in);

如何传入对象:
static class Print implements Runnable {
private PipedReader in;
public Print(PipedReader in) {
this.in = in; }
}
————

一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才 从thread.join()返回

—————
可以看到,线程池的本质就是使用了一个线程安全的工作队列连接工作者线程和客户端 线程,客户端线程将任务放入工作队列后便返回,而工作者线程则不断地从工作队列上取出 工作并执行。当工作队列为空时,所有的工作者线程均等待在工作队列上,当有客户端提交了 一个任务之后会通知任意一个工作者线程,随着大量的任务被提交,更多的工作者线程会被 唤醒。
—————

synchronized是基于jvm底层实现的数据同步(不属于AQS),lock是基于Java编写

Lock锁(接口,implement之)是面向使用者的,它定义了使用者与锁交 互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;
AQS队列同步器(类,extends之)面向的是锁的实现者, 它简化了锁的实现方式。AQS是一个同步框架,它提供通用机制来原子性管理同步状态、阻塞和唤醒线程,以及维护被阻塞线程的队列。基于AQS实现的同步器包括:ReentrantLock、Semaphore、ReentrantReadWriteLock、 CountDownLatch和FutureTask。
使用aqs通常声明了一个内部私有的继承于AQS的子类。

锁和同步器很好地隔离了使用者和实现者所需关注的领域。
实现Lock的类:
ReentrantLock() 可重入锁
ReadWriteLock 读写锁
ReentrantReadWriteLock 可重入读写锁
StampedLock 读写锁中读不仅不阻塞读,同时也不应该阻塞写
这些既实现了Lock接口,在其内部的同步器也继承了AQS,使用了AQS自带的同步器来实现Lock接口,这样一来我们自定义一个Lock同步组件就方便很多,AQS给我们省了很多事情

AQS 同步器内部对同步状态的管理:
1独占式同步状态获取与释放:acquireQueued方法。同步器维 护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列 (或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态(这时候还没轮到次节点运行,等释放)。在释放同步状态时,同步 器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。(此为公平锁的前提下)
2共享式同步状态获取与释放:acquireShared方法。 共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态(比如多读)
3独占式超时获取同步状态:基于1,很好理解
在AQS中,同步状态表示锁被一个线程重复获取的次数

什么是可中断获取锁:在Java 5之前,当一 个线程获取不到锁而被阻塞在synchronized之外时,对该线程进行中断操作,此时该线程的中 断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。在Java 5中,同步器 提供了acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中 断,会立刻返回,并抛出InterruptedException。

非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁
公平锁需要维护一个队列,加入了同步队列中当前节点是否有前驱节点的判断,如果该 方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释 放锁之后才能继续获取锁。

公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换,且需要维护一个队列。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
(因为非公平锁中,刚释放锁的线程再次获取同步状态的几率会非常大,所以线程切换少。)

————-

如果当前线程在获取读锁时,写锁已被其他线程 获取,则进入等待状态。
写锁能降级成读锁(锁降级是指把持住(当前拥有的)写锁,再获取到 读锁,随后释放(先前拥有的)写锁的过程,中间有段过程会使得读锁等待,一但释放立即获取)
读锁不能升级(为了可见性)
写锁是一个支持重进入的排它锁
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();

—————
任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、 wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以 实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等 待/通知模式
获取一个Condition必须通过Lock的newCondition()方法
ConditionObject是同步器AbstractQueuedSynchronizer的内部类
一个锁上面可以生产多个Condition。
一个锁对应一个aqs同步器(底层由aqs实现),一个aqs同步器有一个同步队列和多个condition,每个condition包含一个等待队列。调用await方法时,相当于aqs同步队列的首节点移到该condition的等待队列中,signal则相反(signal被唤醒加入同步队列,不是立即执行)
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是 在Condition对象上等待的线程

对比:
lock-aqs拥有一个同步队列和多个等待队列
sychnized-监视器模型是一个同步队列和一个等待队列
——————

并发容器和框架

ConcurrentLinkedQueue 可以非阻塞且安全
先是定位尾指针的位置,然后使用CAS算法能将入队节点设置成尾节点的next节点,如不成功则重定位尾节点并重试。

——————
阻塞队列使用等待/通知模式实现来通知消费者/生产者队列可用的。通知模式,就是比如当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。
比如ArrayBlockingQueue使用了Condition来实现,使用await,signal方法

——————

Fork就是把一个大任务切分 为若干子任务并行的执行,Join就是合并这些子任务的执行结果,最后得到这个大任务的结 果。join和fork都是thread的方法
先分割任务,再合并任务
eg:
CountTask leftTask = new CountTask(start, middle);
CountTask rightTask = new CountTask(middle + 1, end);
// 执行子任务
leftTask.fork();
rightTask.fork();
// 等待子任务执行完,并得到其结果
int leftResult=leftTask.join();
int rightResult=rightTask.join();
// 合并子任务
sum = leftResult + rightResult;

————-——

工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行
去其他线程的队列 里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被 窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿 任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

——————

原子操作类和并发工具类

AtomicBoolean,AtomicInteger,AtomicLong都是通过cas实现
下同
AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray:
AtomicReference原子更新引用
AstomicIntegerFieldUpdater 原子地更新某个类里的某个字段
——————
Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交 换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过 exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也 执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产 出来的数据传递给对方。

线程池

线程池的意义:
第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
第三:提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源, 还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用 线程池,必须对其实现原理了如指掌。

——————

1

队列排的都是竞争核心线程,非核心线程是队列满的时候创建执行(这样反而会导致队满后后来的线程先执行了,所以比如fix线程池,core和max线程数相同,即没有非核心线程)
核心线程(默认情况)下会一直存活在线程池中,即使这个核心线程啥也不干(闲置状态),非核心线程闲置会被销毁(cache线程池的核心数就设置为0,max无界,这样就做到了都是非核心线程,闲置一段时间会被自动销毁,不来任务不创建)
(但CachedThreadPool的maximumPool是无界的也意味着,如果主线程提交任务的速度高于 maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下, CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源。)

2

ThreadPoolExecutor执行execute方法,如图1234四个判断下分别执行的事情

如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务,这里需要全局锁,很耗性能,所以才这么设计

FixedThreadPool的corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指定的参数nThreads。
——————

向线程池提交任务:execute()方法用于提交不需要返回值的任务,submit()方法用于提交需要返回值的任务

性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务应配置尽可能小的 线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配 置尽可能多的线程,如2*Ncpu。混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务 和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量 将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。
依赖数据库连接池的任务属于io密集型,因为线程提交SQL后需要等待数据库返回结果,等待的时间越 长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高 的任务先执行。
——————

FixedThreadPool和SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的 工作队列。
CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列(SynchronousQueue是一个没有容量的阻塞队列。每个插入操作必须等待另一 个线程的对应移除操作)
ScheduledThreadPoolExecutor 使用DelayQueue作为任务队列

——————

Excutor框架

Excutor框架是ThreadPoolExcutor的祖师爷
应用程序通过Executor框架控制上层的调度;而下层的调度由操作系统 内核控制,下层的调度不受应用程序的控制。

3

4

ThreadPoolExecutor执行execute方法,如图1234四个判断下分别执行的事情

如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务,这里需要全局锁,很耗性能,所以才这么设计

FixedThreadPool的corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指定的参数nThreads。
——————

向线程池提交任务:execute()方法用于提交不需要返回值的任务,submit()方法用于提交需要返回值的任务

性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务应配置尽可能小的 线程,如配置Ncpu+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配 置尽可能多的线程,如2*Ncpu。混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务 和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量 将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。
依赖数据库连接池的任务属于io密集型,因为线程提交SQL后需要等待数据库返回结果,等待的时间越 长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU。
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高 的任务先执行。
——————

FixedThreadPool和SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的 工作队列。
CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列(SynchronousQueue是一个没有容量的阻塞队列。每个插入操作必须等待另一 个线程的对应移除操作)
ScheduledThreadPoolExecutor 使用DelayQueue作为任务队列

——————
Excutor框架是ThreadPoolExcutor的祖师爷
应用程序通过Executor框架控制上层的调度;而下层的调度由操作系统 内核控制,下层的调度不受应用程序的控制。

后续

AQS框架的细节以后需要更多了解(源码)


且听风吟