Java设计思想深究----多线程与并发(图文)

本文很长很长,对原理深入至内存层面,以顺序结构讲述集合框架的设计故事,请耐心阅读顺序阅读 或 挑选疑惑点阅读。

目录结构太大,导致点击后索引到内容末尾,需要查看上滑或下滑即可。

目录

一切的缘起是昂贵的CPU

什么是并发?

什么是多线程?

Java中的多线程情况是怎么样的?

Java并发机制中的原子性、有序性、可见性

万能的synchronized关键字?

Monitor对象

Java对象头

synchronized+monitor+对象头 的配合

Synchronized JDK1.6后的优化

备受争议的volatile关键字?

缓存一致性协议

MESI 协议(M:modify E:exclude S:share I:invaild)

volatile为什么不能取代synchronized?

synchronized与Lock的区别

Sleep()与Wait()的区别

如何实现多线程

创建线程的3种方式

线程池的原理

ThreadPoolExecutor 线程池执行器

ScheduledThreadPoolExecutor 计划性线程池执行器

如何选择合适的线程池?

强大的 ConcurrentHashMap 其实并不难

多样的锁与应用场景

数据读写层面的锁

悲观锁

乐观锁

对象层面的锁

无锁

偏向锁

轻量级锁

重量级锁

多线程与并发优化



一切的缘起是昂贵的CPU

我们都十分清楚,计算机的核心是计算,而负责这个功能的组件就是CPU。

CPU有一个特性,在一个时刻只能处理一个程序。

开发人员编写代码,代码被编译为机器语言,CPU收到机器语言(指令集),开始处理程序,而这个正在被CPU处理的程序就是进程(正在进行的程序)

当CPU正在处理一个程序时,由于其特性,其他程序就只能等待。

你可能会想,一个接一个处理,不是很合理的设计吗?

这仅仅对于CPU执行指令而言,的确如此。可是,数据在存储媒介上的I/O速度与CPU的速度相比,是十分缓慢的,这就导致了CPU经常处于被一个进程占用,但很闲的情况。

Java设计思想深究----多线程与并发(图文)_第1张图片

针对这种浪费造价昂贵的CPU的情况,就出现了中断处理机制

中断处理机制 简而言之就是CPU将自己的资源分划为一个一个时间片,根据一定策略分发给待处理的程序集合,[time1,time2]执行程序1(执行了n%,停顿等待下一个时间片)、(time2,time3]一会执行程序2(执行了n%,停顿等待下一个时间片).....因为CPU超快的执行速度,人类观察到的程序似乎没有停顿。放佛在同时运行一般。而CPU则一刻不停地运算着。

什么是并发?

如果我们以1帧(1/60秒)为单位去观察程序,发现程序1、程序2、程序3都同时启动,同时被执行完毕,一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行的现象,我们称之为程序的并发运行

Java设计思想深究----多线程与并发(图文)_第2张图片

  

什么是多线程?

为了提高CPU的资源利用率,我们对CPU的资源抽象为时间,利用分配时间片段实现程序间的并发行为。然而,CPU对于程序之间的切换(上下文切换)的代价还是比较高的。为了缩小提高CPU资源利用率的代价,有一个很简单的做法就是:不进行程序之间上下文切换了。

显然这种做法又回到了原点,CPU又处于I/O过程中的漫长等待。既然单单划分CPU的时间不足以,为什么不把进程(正在进行的程序)也划分成一个一个单位呢?这样CPU在处理进程时,优先在进程中的单位片段间切换,最小可能的避免程序的上下文切换,既提高了CPU的资源利用率,还最小化了程序间上下文切换的代价。于是,我们把进程划分成的单位称之为:线程

线程以并发的机制被CPU处理,使得一个进程整体看起来执行的特别快,这样的机制称之为:多线程。当然,CPU一个时刻也只能将1个时间片分给1个线程,所以,当CPU处理当前线程时,其他线程要么等待、要么睡觉(sleep),调度这些工作的就是操作系统

Java设计思想深究----多线程与并发(图文)_第3张图片 1个进程开2个线程下CPU的利用情况

Java中的多线程情况是怎么样的?

        我们都知道Java的启动类方法psvm(public static void main),实际上这就是一段简单的程序,启动Java项目,会先执行这个程序,psvm便作为进程被CPU处理。由于Java引入了多线程机制,进程会默认分割为1个单位的线程(其实就是没开其他线程的意思),这个线程称之为 主线程

        CPU执行时,载入psvm的上下文后,便执行进程中的线程。

Java并发机制中的原子性、有序性、可见性

        基于以上设计,Java中的并发机制有3个特性:原子性、有序性、可见性。

  • 原子性

简言之,原子性就是指令集合(一行或多行程序)被CPU执行的整个阶段,不会被中断的特性。

举个例子(场景默认为多线程),

count++;

这行程序,在操作系统层面是一个指令集,有3个指令 :

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU的寄存器

  • 指令 2:之后,在寄存器中执行 +1 操作;

  • 指令 3:最后,将结果写入内存

操作系统的任务切换(线程切换)是以指令为单位的,因此此程序在指令2存在着被中断的风险,因此不是原子性的。

再看一个原子性的例子:

count = 0;

这行程序,在操作系统层面是一个指令集,有1个指令:

  • 指令 1:将0写入索引指向的内存地址;

此程序在指令集执行期间不存在被中断的风险,因此是原子性的。

实际上,原子性是一个相对性质。

上述的第一个非原子性案例,当场景为单线程时,实际上也属于原子性操作。

原子性也与观察者的观察粒度有关。

{
count = 0;
count++;
}

以整个程序看,在操作系统层面是一个指令集,有4个指令,存在被中断的风险,因此不少原子性的,即使单独的".count = 0 "是原子性的。 

Java设计思想深究----多线程与并发(图文)_第4张图片

  • 有序性

        有序性指的是程序按照代码的先后顺序执行(即指令集先后顺序执行)。

        为了性能优化,编译器和处理器会进行指令重排序,有时候会改变程序中语句的先后顺序,如果读者有好奇看过.class文件,就会发现,很多时候操作都变化了,这是JDK提供的性能优化编译。(如果想提高代码的编译速度,不妨多尝试编译后的写法)

        当然,这种优化并不会预见多线程情况下的原子性问题。

//Main.java
public static void main(String[] args) {
        int a,b;
        a=b=1;
        b=2;
        a=a+1;
    }
//Main.class
public static void main(String[] args) {
        int b = true;
        int a = 1;
        b = true;
        int a = a + 1;
    }

此时可以看到编译优化后的顺序已经发生变化。

重排序可以提高程序执行的性能,但是代码的执行顺序改变,可能会导致多线程程序出现可见性问题和有序性问题。 

存在数据依赖关系的两个操作,不可以重排序。

保证有序性的方法

代码中的关键字(如:synchronized、volatile),为内存加一层屏障,使得线程被执行时,保证不会被中断。

  • 可见性

        可见性指的是当一个线程修改了共享变量后,其他线程能够立即得知这个修改。

首先我们来看一下Java内存模型设计(JMM Java Memory Model):

Java设计思想深究----多线程与并发(图文)_第5张图片

 程序中被定义的变量都缓存在主内存中。每个线程都有自己的“工作区域”相互并不干扰,保证线程使用到的变量都是拷贝自主内存的数据。因此,线程是无法直接读取主内存的数据

那么线程如何确保不会“脏读”?

一种解决方法就是,当前被执行的线程为进程加排他锁,其他线程处于等待或睡眠状态,等当前线程执行完毕,将副本变量刷新回主内存,这样可以确保下一个线程读取的内存拷贝是正确的。可以使用synchronized、volatile关键字确保以上的实行。

综上所述,为了确保线程的并发运行,需要同时确保程序的原子性、有序性和可见性。往往都可以通过关键字 synchronized与volatile来实现,那么synchronized、volatile到底是什么呢?

万能的synchronized关键字?

通过上述的描述,我们可以总结出,并发会带来的问题:程序间对临界资源的争夺。

而基于并发的多线程模式,同样存在着临界资源争夺问题:线程间的共享变量

对待临界资源问题,有一个很直接且有效的方案:在某一时刻,CPU一定是只处理一个线程,那当然可以让线程完完整整的执行下去,线程完整执行下去的时间段之类观察CPU的处理模式,这不就看起来和单线程一摸一样,当然就不会有并发问题。

上述的这种方案称作 排他锁。这种“锁”会使得其他线程等待当前线程完整执行完毕后去申请CPU的时间片。

Java中提供了synchronized关键字,其作用与排他锁十分相似,我们下面就探究其相似的原因。

Synchronized底层原理

Java 虚拟机中的同步(Synchronization)行为基于进入和退出管程(Monitor)对象(iow.监视器类)实现。

那就好好研究一下Monitor对象

Monitor对象

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,ObjectMonitor中有两个队列,_WaitSet (等待集合)和 _EntryList(待入场序列)。

当多个线程同时访问一段同步代码时,会先都进入 EntryList(待入场序列):

Java设计思想深究----多线程与并发(图文)_第6张图片

 此后会有两种情况:1.线程完整的被执行完毕

Java设计思想深究----多线程与并发(图文)_第7张图片

 2.在执行完毕前,被下达等待wait()指令

Java设计思想深究----多线程与并发(图文)_第8张图片

 以上就是ObjectMonitor类对线程进行管理的方式,该类定义了wait(),notify(),notifyAll() 方法用于线程控制,并由Object与其关联。这也解释了为什么Object中有线程控制的方法,以及为什么所有的Java类都可以进行同步管理(所有类继承Object类,因此继承并获得自己的monitor)。

那么对象的Monitor管理着的Java对象头到底是什么样子的?

Java对象头

Java设计思想深究----多线程与并发(图文)_第9张图片

 对象的实例缓存在堆内存中被管理,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。Java对象头结构如下:

可以看到锁信息就保存在MarkWord中,锁信息包括:

Java设计思想深究----多线程与并发(图文)_第10张图片 其中偏向锁、轻量级锁是JDK1.6引入的概念。我们重点关注重量级锁。

其中重量级锁就是synchronized关键字添加后给对象附加的对象锁。即,我们为对象中的代码段添加synchronized关键字后,在类初始化后,将对象头中的锁状态更新为:10 重量级锁: 

Java设计思想深究----多线程与并发(图文)_第11张图片

 这样我们便得到了一个synchronized+monitor+对象头的关系。

synchronized+monitor+对象头 的配合

  • 加synchronized关键字

当我们编码一个类,并将其一个方法标为synchronized。

  • 对象实例头加重量级锁

启动JVM,类加载完成,进程中实例化该类,在JVM堆内存中生成一个该类的实例,识别出synchronized符号,将对象头中的锁状态更新为重量级锁(10)。

  • 多线程访问该对象

此时Thread1,Thread2都访问了该对象实例,对象的Monitor监视Thread1、Thread2,Thread1、Thread2进入待入场序列(EntryList),由于Thread1在前,Monitor先让Thread1获取对象的锁(将Thread1的线程id写入对象头中),Thread1被执行。Thread1执行过程中,Monitor由于重量级锁协定,始终控制EntryList中的线程等待入场,并不打断Thread1。直到Thread1被执行完毕,Monitor将对象的锁移交给Thread2(将Thread2的线程id写入对象头中)。

小结:synchronized关键字通过以上过程,主动构造了“临时单线程”场景,确保了原子性与有序性。通过Monitor的EntryList与WaitSet有序等待机制,确保了当前线程的修改副本可以在其他线程访问前刷新回主内存,由此确保了可见性。

Synchronized JDK1.6后的优化

在上述“synchronized+monitor+对象头 的配合”过程中,默认使用了重量级锁,实际上JDK1.6后对synchronized进行了性能优化:

  1. 轻量级锁:指虽然代码中有synchronized关键字加锁,但jvm在执行时,不存在并发问题,这时jvm会优化成轻量级锁;
  2. 为了减少初始化时间,JVM默认延时(Sleep)加载偏向锁。

至此,结束了对Synchronized的介绍,有Synchronized的保驾护航,我们便可以安心的开始多线程的解析了。

备受争议的volatile关键字?

在 Java 5 之前,它是一个备受争议的关键字,因为在程序中使用它往往会导致出人意料的结果。在 Java 5之后,volatile 关键字才得以重获生机。

缓存一致性协议

        volatile(易变)是Java中的关键字。其作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。

换言之,volatile修饰的代码具有两个特征:

  1. 保证变量的内存可见性
  2. 禁止指令重排序,保证变量都有序性

可见性与有序性上文有详细的解释,注意没有保证原子性

没有保证原子性会导致什么问题?

Java中long和double类型变量占8字节,如果变量非volatile熟悉,JMM在读写这两种变量时,时分两次进行的,每次读写4字节。在多线程环境下,如果多个线程同时操作一个long或double类型变量时,如果该变量有可能在32位系统多线程环境下使用,且没有其他同步机制,应增加volatile属性。否则会导致:线程A读取变量的前4个字节来自线程B的写入,后4个字节来自线程C的写入。

在了解了JMM模型的基础上,我们不难怀疑,如果一个变量在多个CPU中都存在缓存,那么就可能存在缓存不一致的问题

Java设计思想深究----多线程与并发(图文)_第12张图片

上述问题synchronized给出的解决方案是总线(主内存)加锁

volatile提供了另一种解决方案:缓存一致性协议

起因就是因为总是给主内存加锁,在加锁的过程中如果频繁的发生I/O(数据读写),就又会出现CPU等待的问题,显得效率低下,于是缓存一致性协议应运而生,试图解决这个问题。

提到缓存一致性协议,最出名的就是Intel 的 MESI 协议。

MESI 协议(M:modify E:exclude S:share I:invaild)

        MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。

它的原理并不难,就是经典的观察者模式(当对象出现修改时,会自动通知依赖它的所有对象)。

当线程对变量进行修改后,主存上的缓存一致性协议机制会判断是否为共享变量,如果是共享变量,则会向其他共享着这个变量的线程的副本CPU发出信号,通知其这个变量已失效,如果使用到需要重新从主存获取刷新。

Java设计思想深究----多线程与并发(图文)_第13张图片

volatile为什么不能取代synchronized?

如果说synchronized是防止多个线程并发的访问一块主存资源,浪费CPU资源。

那volatile就是允许多个线程并发的访问一块主存资源(非直接),提高了CPU资源利用率,但是牺牲了并发的原子性保证,同时MESI协议需要一定的资源消耗,只能保证读操作的线程安全性

因此结论就是volatile不等同于synchronized,也就无法取代

如何选取策略在于 MESI协议需要一定的资源消耗 vs. CPU等待的资源消耗

一般认为,指令集中的指令确保原子性的前提下,volatile的效率更高。

如果说synchronized是对象锁,那么volatile就是分段锁,从解耦而言,volatile更灵敏。

volatile的应用在ConcurrentHashMap类中大显神通,本文下文将会详细介绍ConcurrentHashMap。

synchronized与Lock的区别

首先,synchronized是Java的关键字,在jvm层面上的。

而Lock是Java提供的一个接口类,提供了代码层面的同步方法和锁操作,更加灵活。

synchronized以获取锁的线程执行完同步代码,释放锁,如果发生异常,jvm会让线程释放锁。

Lock必须主动在finally中必须释放锁,不然容易造成线程死锁。

Sleep()与Wait()的区别

sleep()是Thread类的方法,Wait是JVM层面Monitor为Object提供的方法。

最核心的点在于:sleep()是线程等待,时间过后会尝试获取对象锁。

                             wait()是线程挂起,在notify()之前都不会尝试申请CPU资源去获取对象锁。

如何实现多线程

创建线程的3种方式

进入Thread类的resource,注释里有标准的介绍:

  • 继承Thread
class PrimeThread extends Thread {
  long minPrime;
  PrimeThread(long minPrime) {
  this.minPrime = minPrime;
  }
  
  public void run() {
  // compute primes larger than minPrime
     . . .
  }
}

//The following code would then create a thread and start it running:
  PrimeThread p = new PrimeThread(143);
  p.start();
  • 实现Runnable接口
       class PrimeRun implements Runnable {
           long minPrime;
           PrimeRun(long minPrime) {
               this.minPrime = minPrime;
           }
  
           public void run() {
               // compute primes larger than minPrime
                . . .
           }
       }

//The following code would then create a thread and start it running:
       PrimeRun p = new PrimeRun(143);
       new Thread(p).start();

还有一种Thread注释没有介绍的: 

  • 实现Callable接口
public class PrimeCall implements Callable {
    @Override
    public Object call() throws Exception {
        //...
        return null;
    }
}

//启动方式
PrimeCall t = new PrimeCall();
FutureTask futureTask = new FutureTask(t);
new Thread(futureTask).start();

启动线程:

PrimeThread t1 = new PrimeThread();
PrimeRun t2 = new PrimeRun();
PrimeCall t3 = new PrimeCall();

//注意,线程启动都是通过start()启动,run()方法会委托给内部启动
t1.start();
t2.start();
//FutureTask可包装Callable或Runnable对象,FutureTask+Thread获得线程的计算结果
FutureTask futureTask = new FutureTask(t3);
new Thread(futureTask).start();

        上面三种方式更推荐通过实现 Runnable接口和实现 Callable接口,因为面向接口编程拓展性更好,而且可以防止 java 单继承的限制。Runnable接口是没有返回值的,如果需要返回值使用Callable接口

以上是手动创建线程的过程,可以发现启动并不是直接调用run()方法,而是start(),这其中的原理是这样的:

  • 直接调用run()方法

        是可以执行到run()方法中的程序,但是,线程并没有被激活,也就是说,创建了n个线程,如果没有委托激活,那么整个进程还是单线程的,调用run()方法的还是main主线程。

  • 激活线程start()

        我们都知道,创建线程后,调用start()方法,放佛就可以执行到run()中的程序,显然是存在着某种代理模式。

这是因为线程被激活后开始一系列的自动的工作:当前线程执行start()方法,将待激活线程对象加入group中,并执行start0内部方法,这个方法由C++编写,大致为:Java虚拟机调用这个线程的run方法使该线程开始执行。

综上所述,我们可以得到1个线程的生命周期:

 其中,除了运行由this线程负责。其他:创建与回收由JVM线程负责,激活由当前线程负责。如果1个用户的访问就运行一遍完整的线程生命周期,那么n个用户就会产生n倍的除运行外的代价,显然,一个系统的并发访问是有浮动上限的,那么,便有了一种提高性能的办法,用计算机的存储特性替代计算特性:预处理线程的创建,等需求来临时直接引用激活运行,但不回收,继续等待下一个需求。这样预处理、异步响应的机制就是典型的生产者-消费者模型,产品为线程时,我们称作该模式为线程池机制:

Java设计思想深究----多线程与并发(图文)_第14张图片

线程池的原理

使用线程池主要有以下两个好处:

  1. 减少在创建和销毁线程上所花的时间以及系统资源的开销 

  2. 如不使用线程池,有可能造成系统创建大量线程而导致消耗完系统内存 。

那么,JDK为我们提供的线程池是什么样的?ThreadPoolExecutor类与ScheduledThreadPoolExecutor类提供了一个可扩展的线程池实现,属于大名鼎鼎的Concurrent框架中的一员:

Java设计思想深究----多线程与并发(图文)_第15张图片

 我们通过解读Executor接口(执行协议),了解到Concurrent框架中将每个线程要执行的指令集合封装为RunnableTask(可运行状态任务),实现Executor接口的实体将提供了一种将任务提交与每个任务如何运行的机制(包括线程使用、调度等细节),像这样:

   Executor executor = anExecutor;
   executor.execute(new RunnableTask1());
   executor.execute(new RunnableTask2());
   ...

隐式地为创建的线程激活,进而由JVM代理执行任务。

ExecutorService,这是一个更广泛的接口,对Executor协议进行了一层扩展服务包装,执行器服务协议将执行器描述为一个终端模型,即对线程的“生产者-消费者”模型进行了行为规定。并对RunnableTask进行了扩展:RunnableTask被Executor执行后的结果封装为Future。即,实现执行器服务协议的终端,数据流输入输出流被规定。

在此进行一个小结,Concurrent框架将线程封装为一个个可运行状态的任务(RunnableTask),并委托Executor(执行器)在终端去执行线程程序,并返回一个Future(未来结果)。

ThreadPoolExecutor 线程池执行器

AbstractExecutorService抽象类实现了上述协议的逻辑过程,子类ThreadPoolExecutor实体化逻辑过程,并在此基础上完善了整个线程池模型,主要实现过程为:

  • 核心线程 workers (体制内员工)

核心线程就是线程池的固定员工,我们可以通过:

private volatile int corePoolSize;
public void setCorePoolSize(int corePoolSize){...}

来维护,它是线程池高并发的同时低开销的核心。

        我们上面提到了,线程的创建-激活与回收都是与运行程序无关,但需要系统开销的系统,既然如此,我们就委托线程池安排几名线程作为“体制内员工(内部Worker类)”,找个个数就由corePoolSize来规定。当然,为了开销最小,线程池都是按需创建线程的。

比如:

开发者第一个“安装”了一个线程池,并且封装了一个RunnableTask,丢给线程池。

->线程池作为服务终端接到任务后,指派Executor去完成这个任务

->于是Executor.execute(task):看了一下corePoolSize规定,还没满(workers.size()

  • 阻塞队列 workQueue (工作队列)

开发者又有几个任务了,干脆全封装好,丢给了线程池。

 ->线程池作为服务终端接到任务后,指派Executor去完成这个任务

->于是Executor.execute(task),就这么几次过后···达到corePoolSize规定,不能再新找员工了。Executor只能把这个task放到工作队列中(workQueue),因为Executor清楚,线程池里只是暂时没有可用的员工了,并不是没有员工了,等某个员工处理好手头的事情后,便会来看看workQueue里有无工作可接。(这个队列为LinkedBlockingDeque)

->当然也有workQueue无工作可做的时候,让员工被激活干巴巴地站着等无疑于资源浪费(因为激活他们需要系统开销),于是线程池将工作队列设计成阻塞队列:阻塞即当员工申请工作队列中的工作时,发现没有工作可做,便进入等待状态wait()。线程处于wait()时,是不占用任何CPU资源的。

  • 额外线程 workQueue (临时员工)

开发者突然有很多任务,干脆全封装好,丢给了线程池。

 ->线程池作为服务终端接到任务后,指派Executor去完成这个任务

-> 于是Executor.execute(task),就这么几次过后···达到corePoolSize规定(核心线程的最大数量),不能再新找员工了。Executor只能试图把这个task放到工作队列中(workQueue)。但是发现此时的workQueue是满的(workQueue达到了容量极限),此时就只能检查maximumPoolSize规定(这个规定是线程池最大的线程数),如果workers.size()

开发者竟穷追不舍在饱和的状态下又丢给了线程池一些任务。

 ->线程池作为服务终端接到任务后,指派Executor去完成这个任务

-> 于是Executor.execute(task),就这么几次过后···达到corePoolSize规定(核心线程的最大数量),不能再新找员工了。Executor只能试图把这个task放到工作队列中(workQueue)。但是发现此时的workQueue是满的(workQueue达到了容量极限),此时就只能检查maximumPoolSize规定(这个规定是线程池最大的线程数),发现workers.size() == maximumPoolSize,这次是彻底饱和了,Executor只能拒绝本次任务(reject(task)),并响应开发者:RejectedExecutionException(本次执行被拒绝)

开发者看到线程池返回的饱和消息后,减缓了任务的委托。

->workers努力与CPU合作将workQueue的任务清理的差不多了出现了有worker空闲的情况;

->临时工的加入导致了线程池作为服务终端便启动了“摸鱼淘汰制”(keepAliveTime):这个制度会记录员工(现场)没有工作的时长,率先达到keepAliveTime时长的会被请离(workers.remove()),直到workers.size() == corePoolSize,线程池就会停止这个制度。由于体制内员工与临时员工实际上没有明显的区别,因此“大逃杀”模式下,谁都可能被线程池干掉。

自此结束了线程池的介绍,其中员工制度是为了通俗地描述线程池工作流程而讲,读者请熟悉学术词汇,设计的操作在ThreadPoolExecutor均有出现。

总结: 

Java设计思想深究----多线程与并发(图文)_第16张图片

ScheduledThreadPoolExecutor 计划性线程池执行器

ScheduledThreadPoolExecutor继承了ThreadPoolExecutor,并在此基础上扩展了额外安排命令在给定的延迟后运行,或者定期执行。当需要多个工作线程时,或者需要ThreadPoolExecutor(该类扩展)的额外灵活性或功能时,这个类比Timer更可取。

ScheduledThreadPoolExecutor与ThreadPoolExecutor的区别在于WorkQueue:

Java设计思想深究----多线程与并发(图文)_第17张图片

DelayQueue是一个无界队列,所以ThreadPoolExecutor的maximumPoolSize在ScheduledThreadPoolExecutor中无意义,实际上实例化时便不提供无关字段的入参。

ScheduledThreadPoolExecutor中有两个方法:

  • scheduleAtFixedRate 以固定周期计划
  • scheduleWithFixedDelay 以固定延迟计划

它们的作用同方法名,延迟后运行,或者定期执行。

其原理为:当调用计划方法时,会向Pool的DelayQueue中offer一个ScheduleFutureTask 未来计划任务。员工(线程)从DelayQueue上获取到任务后,会根据ScheduleFutureTask 未来计划任务执行任务,以此达到计划的目的。

如何选择合适的线程池?

Executors类为这个包中提供的执行器服务提供工厂方法。这个包中定义了Executor、ExecutorService、ScheduledExecutorService、ThreadFactory和Callable类的工厂和实用方法。

下面介绍3种常用无计划线程池的(实现原理实际上就是帮开发者提供了ThreadPoolExecutor()中的参数

  1. newFixedThreadPool 固定数量线程池,数量通过传入的参数决定。(corePoolSize==MaximunPoolSize=fixedNumber)

  2. newSingleThreadExecutor 创建一个线程容量的线程池,所有的线程依次执行,相当于创建固定数量为 1 的线程池。(corePoolSize==MaximunPoolSize=1)

  3. newCachedThreadPool 可缓存线程池,。如果用空闲线程等待时间超过一分钟,就关闭该线程。(corePoolSize=0;MaximunPoolSize=Integer.MAX_VALUE;KeepAliveTime=60s)

和2种常用的计划线程池实现原理实际上就是帮开发者提供了ScheduledThreadPoolExecutor()中的参数

  1. newScheduledThreadPool 计划线程池 (其实就是ScheduledThreadPoolExecutor

  2. newSingleThreadScheduledExecutor 单线程池延迟任务( ScheduledThreadPoolExecutor(corePoolSize= 1) )

        根据实际业务需求,选择JDK提供的便捷线程池执行器。

强大的 ConcurrentHashMap 其实并不难

        在Java设计思想深究----集合框架数学原理(图文)_kevinmeanscool的博客-CSDN博客

一文中,详细的解析了HashMap的工作原理,并提到了HashMap不是线程安全的。当时提到了线程安全的HashMap:ConcurrentHashMap,这里将详细介绍。

ConcurrentHashMap是支持检索的完全并发性和更新的高期望并发性的哈希表。该类遵循与Hashtable相同的功能规范,并包含与Hashtable的每个方法对应的方法版本,因此该类与Hashtable完全可互操作。

其中2次幂实力话容量、扩容、增删改查、哈希冲突、预防哈希冲突攻击等与HashMap原理相同,并在此基础上将共享变量做了如下处理:

/* ---------------- Fields -------------- */


    transient volatile Node[] table;

    private transient volatile Node[] nextTable;

    private transient volatile long baseCount;

    private transient volatile int sizeCtl;

    private transient volatile int transferIndex;

    private transient volatile int cellsBusy;

    private transient volatile CounterCell[] counterCells;

即通过volatile关键字,实现共享变量的缓存一致性协议,即MESI协议(上文有详细提到)。使得变量的有序性、可见性得到了保障,同时保障了ConcurrentHashMap读操作(get)的线程安全。但写操作(put)不具有原子性,还是需要加锁(加Synchronized关键字)实现线程安全

值得一提的是ConcurrentHashMap还是存在一定的线程阻塞行为:迭代器被设计为一次只能被一个线程使用。这与集合框架中的大部分集合相同,这也是为什么通过iterator迭代集合是线程安全的。

由于volatile对CPU的利用特性,在并发读的情况下,ConcurrentHashMap要比代理HashMap(Collections.synchronizedMap)更高效。

多样的锁与应用场景

这里吐槽一下,锁的种类命名是真的···复杂,只不过根据应用场景不同命名不同。这里介绍几个常用的锁设计:

数据读写层面的锁

悲观与乐观的区别在于是否信任会话的来临是否均匀。

悲观锁

悲观认为本会话访问数据的时候会话的来临十分频繁,如果我不强烈占有这部分数据,将无法保证数据的安全,因此悲观锁将在会话访问数据时占有数据,保证数据的排他性。简而言之:对主内存数据加排他锁。

优点:数据十分安全

缺点:高并发场景CPU资源利用率低

应用:数据库访问时SQL添加 for update

乐观锁

乐观认为本会话访问数据的时候会话的来临很均匀,无需占有数据,只需要在访问数据时验证一下数据是否被更新即可。简而言之:一致性协议的应用。

优点:高并发场景CPU资源利用率高

缺点:无法阻止外源系统也参与事务队列中

应用:字段增加version,增加版本校验

对象层面的锁

synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。

无锁

状态其实就是上面讲的乐观锁。

偏向锁

Java偏向锁(Biased Locking)是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁。

轻量级锁

当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁,使用CAS操作(请求对象给锁,成功就停,不成功就循环这个过程retry)避免了使用互斥量的开销。

重量级锁

就是互斥锁。当前线程运行时其他线程全部阻塞(老实在entryList等待)。

多线程与并发优化

其实优化就是对于矛盾的取舍。

CPU资源利用率 vs 数据安全

->策略的选择:synchronized与volatile的选择

->线程池的应用:核心线程数与最大线程数的选择、KeepAliveTime的宽容度、WorkQueue的弹性等

这些都需要做严谨的数学模型分析,比如线性规划:最小的代价最大的回报等。此类算法问题将在算法博文展开描述。

自此结束了多线程与并发的原理探究,作者水平有限,如有疑论,清不吝评论。

你可能感兴趣的:(Java语言与设计思想,java,后端,并发,多线程,线程池)