Java并发编程的艺术阅读笔记

Java并发编程的艺术

Zrq对这本书的阅读笔记(导入后发现图片上传不了,后面找段时间处理一下)

JUC包相关类:

[外链图片转存失败(img-CkzLiyHK-1562680933166)(…/resource/并发编程JUC包.jpg)]

并发基础

一、为啥要并发?

1.上下文切换

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

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

  1. 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  2. CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  3. 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  4. 协程(一个执行完到下一个执行):在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

2.资源限制

定义:资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源

引发的问题:在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间

解决资源限制问题

  1. 对于硬件资源限制,可以考虑使用集群并行执行程序。
  2. 对于软件资源限制,可以考虑使用资源池将资源复用。

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

3.死锁

定义:如果一个进程(线程)集合中的每个进程都在等待只能由该进程集合中的其他进程才能引发的事件,那么,该进程集合就是死锁的。

资源死锁的条件:

资源互斥

占有资源和等待请求新的资源

不可抢占:资源已被分配给某个进程,其他进程不可抢占,只能由该进程显示释放该资源

循环等待:死锁发生时,系统中一定有两个或两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源

避免死锁的几个常见方法(破坏死锁的四个条件):

  1. 避免一个线程同时获取多个锁。
  2. 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
  3. 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
  4. 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

二、并发的底层实现原理

Java中所使用的并发机制依赖于JVM的实现和CPU的指令

1.volatile

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据到处理器缓存里。(一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入)

volatile的两条实现原则:

1)Lock前缀指令会引起处理器缓存回写到内存。

锁定处理器的这块内存区域的缓存并且该线程回写线程缓存到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据

2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性

处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致(多个线程各自的工作内存与多个线程共享内存的数据一致)。

volatile特性:

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

对volatile写和volatile读的内存语义做个总结:

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

volatile重排序规则表:

[外链图片转存失败(img-jMBy609D-1562680933167)(…/resource/volatile重排序规则表.png)]

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

2.synchronizd

利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

  1. 对于普通同步方法,锁是当前实例对象。
  2. 对于静态同步方法,锁是当前类的Class对象。
  3. 对于同步方法块,锁是Synchonized括号里配置的对象。

从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁

3.原子操作的实现原理

处理器实现原子操作

1)总线锁:使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

2)缓存锁:指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

两种情况处理器不使用缓存锁定:

a.操作的数据不能缓存处理器内部,或操作的数据跨多个缓存行,使用总线锁定

b.处理器不支持缓存锁定

Java实现原子操作

1)循环CAS:旧值A、预期的旧值B、新值V,当A=B时将A更新为V。

三个相关问题:

a. ABA问题。当前值V被修改A后又修改为V

b.循环时间长致使开销大

c.只保证一个共享变量的原子操作

2)锁:保证了只有获得锁的线程才能够操作锁定的内存区域。

三、Java内存模型

1.JMM

有序性,可见性,原子性

①并发两个关键问题:线程间通信(共享内存和消息传递),同步(指程序中用于控制不同线程间操作发生相对顺序的机制)。

②Java共享变量:实例域,静态域和数组元素。

Java并发采用共享内存模型:Java内存模型(JMM)决定一个线程对共享变量的写入何时对另一个线程可见

JMM的抽象概念(提供内存可见性保证):JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory,并不真实存在、涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化),本地内存中存储了该线程以读/写共享变量的副本。

JMM下线程A与线程B之间通信的2个步骤:

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

④重排序的三种类型:

1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

2)指令级重排序(处理器):如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3)内存系统的重排序(处理器)

JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变

final域编译器和处理器要遵守两个重排序规则:
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序(写入早于赋值)。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序(读final的包含对象早于读final字段)。

JMM中,临界区内的代码可以重排序

⑤内存屏障

[外链图片转存失败(img-CvFm6q73-1562680933168)(…/resource/内存屏障.png)]

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。

⑥happens-before规则

  1. 程序顺序规则(前一个操作早于后续操作、仅要求结果可见):一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则(加锁早于解锁):对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则(写比读早):对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

顺序一致性内存模型有两大特性。
1)一个线程中的所有操作必须按照程序的顺序来执行。
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见

单例模式中双重锁定检查,多线程访问 A=new B()会出现重排序问题:

1)原因:new一个对象并赋值步骤是:a.分配对象空间、b.初始化对象、c.赋值、d.访问;由于重排序的原因会造成a->c->b->d(先赋值再初始化,然后可能没初始化这时候另一个线程访问造成错误)

2)解决方式:

1.将变量A声明为volitale

2.使用静态内部类包含变量A

四、线程

1.相关知识

线程的状态:

[外链图片转存失败(img-dRJlZHeW-1562680933169)(…/resource/Java线程的状态.png)]

线程状态转换图:

[外链图片转存失败(img-T4RVFl6r-1562680933170)(…/resource/Java线程状态变迁.png)]

阻塞和等待的区别

阻塞是锁被其他对象持有进入的状态,等待是拿到锁后又不满足条件放弃锁等条件满足后获得锁解除等待状态,即:

阻塞:当一个线程试图获取一个内部的对象锁(非java.util.concurrent库中的锁),而该锁被其他线程持有,则该线程进入阻塞状态。
等待:当一个线程等待另一个线程通知调度器一个条件时,该线程进入等待状态。例如调用:Object.wait()、Thread.join()以及等待Lock或Condition。

关于守护线程的退出:Java虚拟机中没有非Daemon线程,虚拟机需要退出,Java虚拟机中的所有Daemon线程都需要立即终止。

安全的终止线程:正常执行结束,设置标志位或中断位去终止线程。(不建议使用stop,因为stop强制终止时没有处理退出的状态等情况、会产生数据不一致的情况,而这样是不安全的)

2.线程通信

volatile与synchronized的可见性和锁机制

等待通知经典范式(等待方和消费方):

等待方遵循如下原则:

1)获取对象的锁。

2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。

3)条件满足则执行对应的逻辑。

通知方遵循如下原则:

1)获得对象的锁。

2)改变条件。

3)通知所有等待在对象上的线程。

等待超时模式就是在等待/通知范式基础上增加了超时控制(条件是剩余时间大于0即没超时)。

管道

管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。(普通文件通信要从磁盘拷贝到系统内核缓存,再拷贝到应用缓存即JVM的缓存中通信,若是输出还需要拷贝到输出流的缓存区)

Thread.join

线程A执行thread.join,当前线程A等待thread线程终止之后才从thread.join()返回

join的底层实现(满足等待/通知经典范式):

// 加锁当前线程对象
public final synchronized void join() throws InterruptedException {
    // 条件不满足,继续等待
    while (isAlive()) {
        wait(0);
    }// 条件符合,方法返回
}
ThreadLocal

ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

Lock公平锁和非公平锁的内存语义:

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state。
  • 公平锁获取时,首先会去读volatile变量。
  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义

一、队列同步器AbstractQueuedSynchronizer

1.基本介绍

AQS使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作;主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态(由实现方式决定两者都有还是只有其中一种)。

锁和同步器的区别与联系:

锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;

同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。

锁和同步器很好地隔离了使用者和实现者所需关注的领域。

同步器提供的模板方法

基本上分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况:

[外链图片转存失败(img-ow574ck9-1562680933171)(…/resource/阻塞队列同步器方法.png)]

2.同步器是如何完成线程同步

原理

流程:同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

节点的属性类型与名称以及描述:

[外链图片转存失败(img-UGZA7Fso-1562680933172)(…/resource/节点的属性类型与名称以及描述.png)]

同步队列的基本结构

[外链图片转存失败(img-spGSXBhg-1562680933172)(…/resource/同步队列的基本结构.png)]

首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功将自己设置为首节点

独占与共享

独占式同步状态获取就是同一时刻只允许一个线程进入设置为当前的独占线程,其他的线程加入队列,流程如图:

[外链图片转存失败(img-qKZGsiwu-1562680933173)(…/resource/独占式同步状态获取流程.png)]

独占式超时获取同步状态的流程:

[外链图片转存失败(img-EadzQiUk-1562680933173)(…/resource/独占式超时获取同步状态的流程.png)]

共享式同步状态获取就是同一时刻允许多个线程同时获取到同步状态(状态变量值计数)

二、AQS的锁实现

1.重入锁

定义

重入锁ReentrantLock(排他锁、独占锁),就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁,还支持获取锁时的公平和非公平性选择。

获取与释放

重入锁的重入判断逻辑:增加了再次获取同步状态的处理逻辑,通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功

重入锁的释放判断:如果该锁被获取了n次,那么前(n-1)次tryRelease(int releases)方法必须返回false,而只有同步状态完全释放了才能返回true

公平与非公平的获取方式

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO

非公平锁是重入锁的默认实现。

公平性锁保证了锁的获取按照FIFO原则(顺序),而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量

2.读写锁

定义

读写锁ReentrantReadWriteLock,在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁(共享与独占),通过分离读锁和写锁(通过状态变量的高位与低位做锁分离、高位与低位的计数代表共享或独占重入的次数),使得并发性相比一般的排他锁有了很大提升。

ReentrantReadWriteLock的特性:

[外链图片转存失败(img-Vx3QBjEa-1562680933174)(…/resource/ReentrantReadWriteLock的特性.png)]

读写锁状态的划分方式:

[外链图片转存失败(img-lHh3zb8C-1562680933176)(…/resource/读写锁状态的划分方式.png)]

写状态等于S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。

S不等于0时,当写状态(S&0x0000FFFF,只看低位的写状态)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。

//下面的代码的意思就是:S&0x0000FFFF,判断有没有读锁
static final int SHARED_SHIFT   = 16;
        static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

写锁的获取也只需要使用上面的代码判断读锁是否存在即可。

锁降级:写锁降级成为读锁;指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。

三、Condition

Condition定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁

Object的监视器方法与Condition接口的对比:

[外链图片转存失败(img-QICydTt2-1562680933178)(…/resource/Object的监视器方法与Condition接口的对比.png)]

获取一个Condition必须通过Lock的newCondition()方法(使用多次该方法就可获得多个条件队列,虽然返回的Condition对象不一样,但是每个Condition对象所属的AQS是一样的)。

每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键。

等待队列的结构与出/入队

等待队列的基本结构:

[外链图片转存失败(img-7XYo9rKs-1562680933180)(…/resource/等待队列的基本结构.png)]

同步队列与等待队列(等待队列是AQS的内部类,因此每个Condition实例都能够访问同步器提供的方法,相当于每个Condition都拥有所属同步器的引用。下面的图说明了使用一个锁生成的多个条件的AQS是一样的,多个条件相当于对内部AQS的引用,满足条件后等待队列的节点会进入同步队列去获得锁):

[外链图片转存失败(img-ODO7CXgZ-1562680933181)(…/resource/同步队列与等待队列.png)]

当前线程加入等待队列

(当前线程获得锁,但是不满足条件使用await加入等待队列相当于放弃锁):

[外链图片转存失败(img-ukAyYVI9-1562680933183)(…/resource/当前线程加入等待队列.png)]

节点从等待队列移动到同步队列

(满足条件后使用signal唤醒满足的线程节点加入到同步队列等待再次获取锁):

[外链图片转存失败(img-lS23iR4L-1562680933183)(…/resource/节点从等待队列移动到同步队列.png)]

JDK并发容器、框架、工具、原子操作

一、容器

1.阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法(常用于生产者和消费者的场景)。

1)支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。

2)支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。

JDK的7个阻塞队列:

ArrayBlockingQueue:一个由数组结构组成的有界阻塞队。

LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列(默认和最大长度为Integer.MAX_VALUE)。

PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列(默认情况下元素采取自然顺序升序排列)。

DelayQueue:一个使用优先级队列实现的无界阻塞队列(队列的元素必须实现Delayed接口)。

SynchronousQueue:一个不存储元素的阻塞队列(可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合传递性场景)。

LinkedTransferQueue:一个由链表结构组成的无界阻塞队列(相对于其他阻塞队列多了tryTransfer和transfer方法,用来将生产者元素传给消费者)。

LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列(初始化时可以设置容量防止其过度膨胀,可用在“工作窃取模式”、窃取线程从尾部拿、被窃取线程从头部拿)。

阻塞队列实现原理:使用两个Condition(notFull和notEmpty)的等待通知机制实现。

2.Fork/Join框架

Fork/Join框架是Java 7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干 个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

Fork Join的运行流程图:

[外链图片转存失败(img-pbeDuZ5p-1562680933184)(…/resource/Fork Join的运行流程图.png)]

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

框架执行步骤

1)分割任务(一个工作线程分割的子任务会放到其维护的双向队列头部,多个线程取,当队列无任务会从其他工作线程队列尾获取任务);2)执行任务并合并结果(子任务结果放到一个队列,一个线程拿数据合并)

任务状态有4种:已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常(EXCEPTIONAL)

①ForkJoinTask:我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务中执行fork()和join()操作的机制。(fork方法的原理就是任务的入队,唤醒或创建线程执行;join根据任务状态判断来阻塞当前线程并等待获取结果)

②ForkJoinPool:ForkJoinTask需要通过ForkJoinPool来执行。

3.HashMap与ConcurrentHashMap7/8的实现原理

1)JDK7HashMap

[外链图片转存失败(img-kUjQBjgD-1562680933185)(…/resource/Java7的HashMap结构.png)]

put过程:第一个元素插入先初始化数组,然后计算key的hash值找到对应的数组下标,判断key是否已经存在、存在就覆盖值,不存在则添加到数组某项的链表头,当添加时数组大小达到扩容的阈值则新建数组并将旧数组迁移到新的数组(重新计算每个节点的hash值入新数组的新节点链表)

get过程:a.根据 key 计算 hash 值。b. 找到相应的数组下标:hash & (length - 1)。 c.遍历该数组位置处的链表,直到找到相等(==或equals)的 key。

2)JDK7ConcurrentHashMap

[外链图片转存失败(img-QaTto85P-1562680933186)(…/resource/Java7的ConcurrentHashMap结构.png)]

初始化:初始容量initialCapacity,实际操作的时候需要平均分给每个 Segment,并初始化槽的第一个节点;负载因子loadFactor用于每个segment内部的扩容阈值(segment初始化后不可扩容,扩容的是segment槽节点内部的数组)

put过程:a.先根据key值计算槽索引,若是该槽没有初始化,按照第一个槽节点初始化。b.使用segment的put方法,这个过程和hashmap的put过程一致,单个segment的节点超出负载因子定义的扩容大小时该segment需要扩容,过程和hashmap的扩容一样(扩容时使用UNSAFE锁住该segment节点)。

get过程:a.计算 hash 值,找到 segment 数组中的具体位置,或我们前面用的“槽” ;b.槽中也是一个数组,根据 hash 找到数组中具体的位置;c.到这里是链表了,顺着链表进行查找即可

3)JDK8HashMap

数组+链表+红黑树组成,当链表的元素达到8时会转换为红黑树;

Java7 中使用 Entry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode

[外链图片转存失败(img-h6KkmGJn-1562680933186)(…/resource/Java8的HashMap结构.png)]

put过程:与Java7的hashmap的区别在于节点插入是插入到链表的最后面,并且在插入到最后面时会根据该链表循环遍历的次数计算是否达到化为红黑树的阈值。扩容基本也一样,除了多了个红黑树的处理,Java7 是先扩容后插入新值的,Java8 先插值再扩容。

get过程:a.计算 key 的 hash 值,根据 hash 值找到对应数组下标: hash & (length-1) ;b.判断数组该位置处的元素是否刚好就是我们要找的,如果不是,走第三步;c.判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步;d.遍历链表,直到找到相等(==或equals)的 key

4)JDK8ConcurrentHashMap

java8的ConcurrentHashMap结构和HashMap的结构一样,如下:

[外链图片转存失败(img-Cdswihw9-1562680933187)(…/resource/Java8的HashMap结构.png)]

put过程:判断数组是否为空、为空进行初始化并将sizeCtl置为-1表示当前线程在初始化;判断能不能根据hash值获得数组某节点的链表的头结点、该头结点为空直接放入;如果都不是上面的情况判断该hash值是不是处于moved扩容迁移状态,是的话当前线程去帮助迁移(迁移时next数组的节点会被设置为ForwardingNode前向节点-只有hash值为moved,标志迁移状态);最后上述情况都不是那就是获得链表头结点并且不为空进行节点的插入或者更新操作

扩容和迁移:将扩容单个任务分成多个小任务使用多线程进行扩容,第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride (步长,移动距离)个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程。扩容过程使用CAS比较sizeCtl+1的值状态,也是新建next数组来保存迁移值。

(就是next节点数组分成几块,由几个线程去做迁移,做完迁移的线程去检测那些没有迁移完的帮忙迁移就可以,做完迁移会将该节点置为前向节点ForwardingNode;

注:不是特意建立几个线程去做迁移,只是拥有这个机制,刚开始也只是put的那个线程在做迁移使用tranfer方法;当新的线程要使用时检测到这个map的状态为迁移会使用helpTransfer去计算要辅助迁移的节点)

get过程

a.计算 hash 值

b.根据 hash 值找到数组对应位置: (n - 1) & h

c.根据该位置处结点性质进行相应查找

  • 如果该位置为 null,那么直接返回 null 就可以了
  • 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
  • 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树
  • 如果以上 3 条都不满足,那就是链表,进行遍历比对即可

二、原子操作和并发工具类

1.原子操作

Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。Atomic包里的类基本都是使用Unsafe实现的包装类。

2.并发工具

CountDownLatch、CyclicBarrier和Semaphore工具类提供了一种并发流程控制的手段,Exchanger工具类则提供了在线程间交换数据的一种手段。

CountDownLatch允许一个或多个线程等待其他线程完成操作。

CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。(用于多线程计算数据,最后合并计算结果的场景)

Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。(用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接)

Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。(用于遗传算法、校对工作;两个线程有一个没有执行exchange()方法,则会一直等待,如果担心有特殊情况发 生,避免一直等待,可使用exchange(V x,longtimeout,TimeUnit unit)设置最大等待时长)

CountDownLatch与CyclicBarrier区别

CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置。所以CyclicBarrier能处理更为复杂的业务场景。例如,如果计算发生错误,可以重置计数器,并让线程重新执行一次。

线程池

一、池技术

(第四章的线程应用实例一节的内容)

1)使用链表+CountDownLatch实现的一个数据库连接池(拥有获取的超时机制,相当于争抢一个资源)

2)一个拥有同步工作者列表,任务链表的线程池,复用线程(比数据库连接池多了个任务列表,工作者线程判断任务列表是否为空,成功取任务后执行(注意是job.run,而不是start因为工作者线程已经在运行了,是要利用工作者线程而不是新建线程)/没成功就不管,然后根据循环的运行标志决定该工作者是否继续运行)

Java线程池的实现就是这个线程池的复杂版,思路基本不变,增加了锁控制的复杂情况,以及任务队列的复杂情况,还有线程池的线程数量大小判断以及不满足线程池运行情况的拒绝策略(根据这个简化的去理解那个复杂的,线程池这一块瞬间通透了)

3)基于线程池技术的web服务器:将job替换为socket添加到线程池就可以,线程池的泛型声明为请求处理的handler(自定义的包含socket的runnable接口实现类)

二、Java线程池

1.实现原理

当提交一个新任务到线程池时,线程池的处理流程如下。

1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。

2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。

3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

线程池的主要处理流程:

[外链图片转存失败(img-o6gNk3TL-1562680933187)(…/resource/线程池的主要处理流程.png)]

线程池的excute函数:

int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {//线程数小于当前核心线程数
    if (addWorker(command, true))
        return;
    c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {//判断线程池是否在运行并且将任务入队
    int recheck = ctl.get();
    if (! isRunning(recheck) && remove(command))
        reject(command);
    else if (workerCountOf(recheck) == 0)
        addWorker(null, false);
}
else if (!addWorker(command, false))//没有成功使用线程执行或添加到任务队列的拒绝策略,四个内部的策略实现类(不处理,抛异常,抛弃最久等待任务让提交线程自己执行)
    reject(command);

2.线程池的合理配置

任务的性质:CPU密集型任务、IO密集型任务和混合型任务。

1)CPU密集型任务应配置尽可能小的线程,如配置Ncpu+1个线程的线程池。

2)IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*Ncpu。

3)混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解

任务的优先级:高、中和低。(优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先执行。)

任务的执行时间:长、中和短。(不同规模的线程池或优先队列)

任务的依赖性:是否依赖其他系统资源,如数据库连接。(根据等待时间设置线程数,等待越久CPU越空闲线程数越大,使用有界队列增加系统稳定性和预警能力)

三、Executor框架

1.任务的两级调度模型

(在上层,Java多线程程序通常把应用分解为若干个任务,然后使用用户级的调度器(Executor框架)将这些任务映射为固定数量的线程;在底层,操作系统内核将这些线程映射到硬件处理器上。)

[外链图片转存失败(img-PmoxJwwZ-1562680933188)(…/resource/任务的两级调度模型.png)]

HotSpot VM的线程模型中,Java线程(java.lang.Thread)被一对一映射为本地操作系统线程。Java线程启动时会创建一个本地操作系统线程;当该Java线程终止时,这个操作系统线程也会被回收。

2.Executor框架主要由3大部分组成

1)任务。包括被执行任务需要实现的接口:Runnable接口或Callable接口。

2)任务的执行。包括任务执行机制的核心接口Executor,以及继承自Executor的ExecutorService接口。Executor框架有两个关键类实现了ExecutorService接口(ThreadPoolExecutor和ScheduledThreadPoolExecutor)。

3)异步计算的结果。包括接口Future和实现Future接口的FutureTask类。

Executors创建的四种线程池

1)FixedThreadPool。创建使用固定线程数的FixedThreadPool,适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比较重的服务器。

2)CachedThreadPool。创建一个会根据需要创建新线程的CachedThreadPool,是大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器。

3)ScheduledThreadPoolExecutor。创建周期性执行任务的线程池,适用于需要多个后台线程执行周期任务,同时为了满足资源管理的需求而需要限制后台线程的数量的应用场景。

4)SingleThreadExecutor。创建使用单个线程的SingleThreadExecutor 。适用于需要保证顺序地执行各个任务;并且在任意时间点,不会有多个线程是活动的应用场景。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
嗨!很高兴回答你关于Java并发编程的问题。请问你想知道什么方面的内容呢?我可以分享一些学习笔记和建议给你。 1. 并发编程基础:了解并发编程的基本概念,如线程、进程、锁、同步等。学习Java中的并发编程模型以及相关的API,如Thread、Runnable、Lock、Condition等。 2. 线程安全性:学习如何保证多线程环境下的数据安全性,了解共享资源的问题以及如何使用同步机制来防止数据竞争和并发问题。 3. 线程间的通信:掌握线程间的通信方式,如使用wait/notify机制、Lock/Condition等来实现线程的协调与通信。 4. 并发容器:学习并发容器的使用,如ConcurrentHashMap、ConcurrentLinkedQueue等。了解它们的实现原理以及在多线程环境下的性能特点。 5. 并发工具类:熟悉Java提供的并发工具类,如CountDownLatch、CyclicBarrier、Semaphore等,它们可以帮助你更方便地实现线程间的协作。 6. 并发编程模式:学习一些常见的并发编程模式,如生产者-消费者模式、读者-写者模式、线程池模式等。了解这些模式的应用场景和实现方式。 7. 性能优化与调试:学习如何分析和调试多线程程序的性能问题,了解一些性能优化的技巧和工具,如使用线程池、减少锁竞争、避免死锁等。 这些只是一些基本的学习笔记和建议,Java并发编程是一个庞大而复杂的领域,需要不断的实践和深入学习才能掌握。希望对你有所帮助!如果你有更具体的问题,欢迎继续提问。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值