并发编程原理扫盲笔记1

456 阅读19分钟

1.计算机体系结构

冯·诺依曼提出了制造计算机的三个基本原则,这套理论被称作冯·诺依曼体系结构,现代计算机基本都是以冯·诺依曼体系结构为基础的[1] 。

冯·诺依曼体系结构三个基本原则分别是:

  • 采用二进制逻辑
  • 程序存储执行
  • 计算机由五个部分组成

我们都知道,现代计算机由五部分组成,它们分别是运算器、控制器、存储器、输入设备和输出设备。

其中运算器和控制器都属于中央处理器(CPU),是电子计算机的核心配件。

Von Neumann计算机体系结构图:

在计算机中,CPU是核心的硬件资源,承担了所有的计算任务;内存资源承担了运行时数据的保存任务;外存资源(硬盘等)承担了数据外部永久存储的任务。

其中,计算任务的调度、资源的分配由操作系统来统领。应用程序以进程的形式运行于操作系统之上,享受操作系统提供的服务。

2.计算机中的进程

2.1.进程的概念

那么什么是进程呢?简而言之,进程就是程序的一次启动执行。

程序是存放在硬盘中的可执行文件,主要包括代码指令和数据。

一个进程是一个程序的一次启动和执行,是操作系统将程序装入内存,给程序分配必要的系统资源,并且开始运行程序的指令。

现代操作系统中,进程是并发执行的,任何进程都可以同其他进程一起执行。在进程内部,代码段和数据段有自己的独立地址空间,不同进程的地址空间是相互隔离的。

2.2.进程的结构

一般来说,一个进程由程序段、数据段和进程控制块三部分组成[2] 。

进程的大致结构如下图所示:

程序段一般也被称为代码段。代码段是进程的程序指令在内存中的位置,包含需要执行的指令集合;

数据段是进程的操作数据在内存中的位置,包含需要操作的数据集合;

程序控制块(Program Control Block,PCB)包含进程的描述信息和控制信息,是进程存在的唯一标志。

PCB主要由四大部分组成:

(1)进程的描述信息。

主要包括:进程ID和进程名称,进程ID是唯一的,代表进程的身份;进程状态,比如运行、就绪、阻塞;进程优先级,是进程调度的重要依据。

(2)进程的调度信息。

主要包括:程序起始地址,程序的第一行指令的内存地址,从这里开始程序的执行;通信信息,进程间通信时的消息队列。

(3)进程的资源信息。

主要包括:内存信息,内存占用情况和内存管理所用的数据结构;I/O设备信息,所用的I/O设备编号及相应的数据结构;文件句柄,所打开文件的信息。

(4)进程上下文。

主要包括:执行时各种CPU寄存器的值、当前程序计数器(PC)的值以及各种栈的值等,即进程的环境。

在操作系统切换进程时,当前进程被迫让出CPU,当前进程的上下文就保存在PCB结构中,供下次恢复运行时使用。

3.运行在进程中线程

3.1.线程的概念

Java编写的程序都运行在Java虚拟机(JVM)中,每当使用Java命令启动一个Java应用程序时,就会启动一个JVM进程。在这个JVM进程内部,所有Java程序代码都是以线程来运行的,线程是进程运行的最小单元。线程也被称作轻量级进程[3]。

进程执行的完整生命流程,简要来说就是JVM找到程序的入口点main()方法,然后运行main()方法,这样就产生了一个线程,这个线程被称为主线程。当main()方法结束后,主线程运行完成,JVM进程也随即退出。

进程是操作系统资源分配的最小单位,而线程是CPU调度的最小单位。一个进程可以有一个或多个线程,一次线程执行流程就是进程代码段的一次顺序执行流程,各个线程之间共享进程的内存空间、系统资源。线程的出现既充分发挥了CPU的计算性能,又弥补了进程调度过于笨重的问题。进程之间是相互独立的,但进程内部的各个线程之间并不完全独立。各个线程之间共享进程的方法区内存、堆内存、系统资源(文件句柄、系统信号等)。

理论上,在Java程序的进程的内部至少会启动两个线程,一个是main线程,另一个是GC(垃圾回收)线程。

3.2.线程的组成部分

一个标准的线程主要由三部分组成,即线程描述信息、程序计数器(Program Counter,PC)和栈内存。

1、线程描述信息

在线程的结构中,线程描述信息即线程的基本信息,主要包括:

(1)线程ID(Thread ID,线程标识符)。线程的唯一标识,同一个进程内不同线程的ID不会重叠。

(2)线程名称。主要是方便用户识别,用户可以指定线程的名字,如果没有指定,系统就会自动分配一个名称。

(3)线程优先级。表示线程调度的优先级,优先级越高,获得CPU的执行机会就越大。

(4)线程状态。表示当前线程的执行状态,为新建、就绪、运行、阻塞、结束等状态中的一种。

(5)其他。例如是否为守护线程等,后面会详细介绍。

2、程序计数器

在线程的结构中,程序计数器很重要,它记录着线程下一条指令的代码段内存地址。

3、栈内存

在线程的结构中,栈内存是代码段中局部变量的存储空间,为线程所独立拥有,在线程之间不共享。在JDK 1.8中,每个线程在创建时默认被分配1MB大小的栈内存。栈内存和堆内存不同,栈内存不受垃圾回收器管理。

3.3.线程的核心原理

现代操作系统(如Windows、Linux、Solaris)提供了强大的线程管理能力,Java不需要再进行独立的线程管理和调度,而是将线程调度工作委托给操作系统的调度进程去完成。在某些系统(比如Solaris操作系统)上,JVM甚至将每个Java线程一对一地对应到操作系统的本地线程,彻底将线程调度委托给操作系统。

由于CPU的计算频率非常高,每秒计算数十亿次,因此可以将CPU的时间从毫秒的维度进行分段,每一小段叫作一个CPU时间片。对于不同的操作系统、不同的CPU,线程的CPU时间片长度都不同。假定操作系统(比如Windows XP)线程的时间片长度为20毫秒,在一个2GHz的CPU上,一个时间片可以计算的次数是20亿/(1000/20)=4000万次,也就是说,一个时间片内的计算量是非常巨大的。

目前操作系统中主流的线程调度方式是:基于CPU时间片方式进行线程调度。线程只有得到CPU时间片才能执行指令,处于执行状态,没有得到时间片的线程处于就绪状态,等待系统分配下一个CPU时间片。由于时间片非常短,在各个线程之间快速地切换,因此表现出来的特征是很多个线程在“同时执行”或者“并发执行”。

线程的调度模型目前主要分为两种:分时调度模型和抢占式调度模型。

线程的调度模型目前主要分为两种:分时调度模型和抢占式调度模型。

(1)分时调度模型:系统平均分配CPU的时间片,所有线程轮流占用CPU,即在时间片调度的分配上所有线程“人人平等”。

(2)抢占式调度模型:系统按照线程优先级分配CPU时间片。优先级高的线程优先分配CPU时间片,如果所有就绪线程的优先级高的线程优先分配CPU时间片,如果所有就绪线程的优先级相同,那么会随机选择一个,优先级高的线程获取的CPU时间片相对多一些。

由于目前大部分操作系统都是使用抢占式调度模型进行线程调度,Java的线程管理和调度是委托给操作系统完成的,与之相对应,Java的线程调度也是使用抢占式调度模型,因此Java的线程都有优先级。

4.线程池

4.1.线程池配置

使用标准构造器ThreadPoolExecutor创建线程池时,会涉及线程数的配置,而线程数的配置与异步任务类型是分不开的。一般情况下,针对以上不同类型的异步任务需要创建不同类型的线程池,并进行针对性的参数配置。

这里将线程池的异步任务大致分为以下三类:IO密集型任务、CPU密集型任务、混合型任务。

(1)IO密集型任务

此类任务主要是执行IO操作。由于执行IO操作的时间较长,导致CPU的利用率不高,这类任务CPU常处于空闲状态。Netty的IO读写操作为此类任务的典型例子。

由于IO密集型任务的CPU使用率较低,导致线程空余时间很多,因此通常需要开CPU核心数两倍的线程。当IO线程空闲时,可以启用其他线程继续使用CPU,以提高CPU的使用率。

Netty的IO处理任务就是典型的IO密集型任务。所以,Netty的Reactor(反应器)实现类(定制版的线程池)的IO处理线程数默认正好为CPU核数的两倍。

(2)CPU密集型任务

此类任务主要是执行计算任务。由于响应时间很快,CPU一直在运行,这种任务CPU的利用率很高。

CPU密集型任务也叫计算密集型任务,其特点是要进行大量计算而需要消耗CPU资源,比如计算圆周率、对视频进行高清解码等。CPU密集型任务虽然也可以并行完成,但是并行的任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以要最高效地利用CPU,CPU密集型任务并行执行的数量应当等于CPU的核心数。

比如4个核心的CPU,通过4个线程并行地执行4个CPU密集型任务,此时的效率是最高的。但是如果线程数远远超出CPU核心数量,就需要频繁地切换线程,线程上下文切换时需要消耗时间,反而会使得任务效率下降。因此,对于CPU密集型的任务来说,线程数等于CPU数就行。

(3)混合型任务

混合型任务任务既要执行逻辑计算,又要进行IO操作(如RPC调用、数据库访问)。相对来说,由于执行IO操作的耗时较长(一次网络往返往往在数百毫秒级别),这类任务的CPU利用率也不是太高。Web服务器的HTTP请求处理操作为此类任务的典型例子。

混合型任务既要执行逻辑计算,又要进行大量非CPU耗时操作(如RPC调用、数据库访问、网络通信等),所以混合型任务CPU的利用率不是太高,非CPU耗时往往是CPU耗时的数倍。比如在Web应用中处理HTTP请求时,一次请求处理会包括DB操作、RPC操作、缓存操作等多种耗时操作。一般来说,一次Web请求的CPU计算耗时往往较少,大致在100~500毫秒,而其他耗时操作会占用500~1000毫秒,甚至更多的时间。

在为混合型任务创建线程池时,如何确定线程数呢?业界有一个比较成熟的估算公式,具体如下:

最佳线程数 = ((线程等待时间+线程CPU时间) / 线程CPU时间) * CPU核数

经过简单的换算,以上公式可进一步转换为:

最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1) * CPU核数

通过公式可以看出:等待时间所占的比例越高,需要的线程就越多;CPU耗时所占的比例越高,需要的线程就越少。下面举一个例子:比如在Web服务器处理HTTP请求时,假设平均线程CPU运行时间为100毫秒,而线程等待时间(比如包括DB操作、RPC操作、缓存操作等)为900毫秒,如果CPU核数为8,那么根据上面这个公式,估算如下:

(900毫秒 + 100毫秒) / 100毫秒 * 8 = 10 * 8 = 80

经过计算,以上案例中需要的线程数为80。很多小伙伴认为,线程数越高越好。那么,使用很多线程是否就一定比单线程高效呢?答案是否定的,比如大名鼎鼎的Redis就是单线程的,但它却非常高效,基本操作都能达到十万量级/秒。

为什么Redis使用单线程如此之快,原因在于:Redis基本都是内存操作,在这种情况下单线程可以高效地利用CPU,多线程会带来线程上下文切换的开销,单线程就没有这种开销。

由于Redis基本都是内存操作,在这种情况下单线程可以高效地利用CPU,多线程反而不是太适用。多线程适用的场景一般是:存在相当比例非CPU耗时操作,如IO、网络操作,需要尽量提高并行化比率以提升CPU的利用率。

以上公式的估算结果仅仅是理论最佳值,在生产环境中的使用也仅供参考。生产环境需要结合系统网络环境和硬件情况(CPU、内存、硬盘读写速度)不断尝试,获取一个符合实际的线程数值。

4.2.ThreadLocal原理

在Java的多线程并发执行过程中,为了保证多个线程对变量的安全访问,可以将变量放到ThreadLocal类型的对象中,使变量在每个线程中都有独立值,不会出现一个线程读取变量时被另一个线程修改的现象。ThreadLocal类通常被翻译为“线程本地变量”类或者“线程局部变量”类。

ThreadLocal的英文字面意思为“本地线程”,实际上ThreadLocal代表的是线程的本地变量,可能将其命名为ThreadLocalVariable更加容易让人理解。

ThreadLocal为什么是线程安全的?

“线程本地变量”可以看成专属于线程的变量,不受其他线程干扰,保存着线程的专属数据。当线程结束后,每个线程所拥有的那个本地值会被释放。在多线程并发操作“线程本地变量”的时候,线程各自操作的是自己的本地值,从而规避了线程安全问题。

ThreadLocal有什么价值?

(1)线程隔离

ThreadLocal的主要价值在于线程隔离,ThreadLocal中的数据只属于当前线程,其本地值对别的线程是不可见的,在多线程环境下,可以防止自己的变量被其他线程篡改。另外,由于各个线程之间的数据相互隔离,避免了同步加锁带来的性能损失,大大提升了并发性的性能。

通常用于同一个线程内,跨类、跨方法传递数据时,如果不用ThreadLocal,那么相互之间的数据传递势必要靠返回值和参数,这样无形之中增加了这些类或者方法之间的耦合度。

(2)跨函数传递数据

由于ThreadLocal的特性,同一线程在某些地方进行设置,在随后的任意地方都可以获取到。线程执行过程中所执行到的函数都能读写ThreadLocal变量的线程本地值,从而可以方便地实现跨函数的数据传递。使用ThreadLocal保存函数之间需要传递的数据,在需要的地方直接获取,也能避免通过参数传递数据带来的高耦合。

在“跨函数传递数据”场景中使用ThreadLocal的典型案例为:可以为每个线程绑定一个Session(用户会话)信息,这样一个线程所有调用到的代码都可以非常方便地访问这个本地会话,而不需要通过参数传递。

ThreadLocal实现原理是什么?

在早期的JDK版本中,ThreadLocal的内部结构是一个Map,其中每一个线程实例作为Key,线程在“线程本地变量”中绑定的值为Value(本地值)。早期版本中的Map结构,其拥有者为ThreadLocal,每一个ThreadLocal实例拥有一个Map实例。

在JDK 8版本中,ThreadLocal的内部结构发生了演进,虽然还是使用了Map结构,但是Map结构的拥有者已经发生了变化,其拥有者为Thread(线程)实例,每一个Thread实例拥有一个Map实例。另外,Map结构的Key值也发生了变化:新的Key为ThreadLocal实例。

在JDK 8版本中,每一个Thread线程内部都有一个Map(ThreadLocalMap),如果给一个Thread创建多个ThreadLocal实例,然后放置本地数据,那么当前线程的ThreadLocalMap中就会有多个“Key-Value对”,其中ThreadLocal实例为Key,本地数据为Value。

ThreadLocal的操作都是基于ThreadLocalMap展开的,而ThreadLocalMap是ThreadLocal的一个静态内部类,其实现了一套简单的Map结构(比HashMap简单)。

ThreadLocal源码中的get()、set()、remove()方法都涉及ThreadLocalMap的方法调用,主要调用了ThreadLocalMap的如下几个方法:

(1)set(ThreadLocal key,Object value):向Map实例设置“Key-Value对”。

(2)getEntry(ThreadLocal):从Map实例获取Key(ThreadLocal实例)所属的Entry。

(3)remove(ThreadLocal):根据Key(ThreadLocal实例)从Map实例移除所属的Entry。

使用ThreadLocal如何避免内存泄漏?

由于ThreadLocal使用不当会导致严重的内存泄漏问题,所以为了更好地避免内存泄漏问题的发生,我们使用ThreadLocal时遵守以下两个原则:

(1)尽量使用private static final修饰ThreadLocal实例。使用private与final修饰符主要是为了尽可能不让他人修改、变更ThreadLocal变量的引用,使用static修饰符主要是为了确保ThreadLocal实例的全局唯一。

(2)ThreadLocal使用完成之后务必调用remove()方法。这是简单、有效地避免ThreadLocal引发内存泄漏问题的方法。

5.并发锁

5.1.CountDownLatch与并发问题

CountDownLatch(倒数闩)是一个非常实用的等待多线程并发的工具类。调用线程可以在倒数闩上进行等待,一直等待倒数闩的次数减少到0,才继续往下执行。每一个被等待的线程执行完成之后进行一次倒数。所有被等待的线程执行完成之后,倒数闩的次数减少到0,调用线程可以往下执行,从而达到并发等待的效果

使用示例:在使用CountDownLatch时,先创建了一个CountDownLatch实例,设置其倒数的总数,例子中值为10,表示等待10个线程执行完成。主线程通过调用latch.await()在倒数闩实例上执行等待,等到latch实例倒数到0才能继续执行。

import java.util.concurrent.CountDownLatch;

public class Test {

    // 线程不安全的累加工具类
    public static class NotSafePlus {
        private Integer count = 0;

        //变量值自增
        public void selfPlus() {
            count++;
        }

        // 获取变量值
        public Integer getCount() {
            return count;
        }
    }

    public static final int MAX_TREAD = 10;
    public static final int MAX_TURN = 1000;

    public static void testNotSafePlus() throws InterruptedException {
        // 倒数闩,需要倒数MAX_TREAD次
        CountDownLatch latch = new CountDownLatch(MAX_TREAD);
        NotSafePlus counter = new NotSafePlus();

        // 线程将 counter 累加 MAX_TURN 次
        Runnable runnable = () -> {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            for (int i = 0; i < MAX_TURN; i++) {
                counter.selfPlus();
            }
            // 倒数闩减少一次
            latch.countDown();
            System.out.println("当前线程执行完成:" + Thread.currentThread().getName());
        };

        // 执行 MAX_TREAD 次 counter 累加
        for (int i = 0; i < MAX_TREAD; i++) {
            new Thread(runnable).start();
        }

        // 等待倒数闩的次数 减少到0,所有的线程执行完成
        latch.await();

        System.out.println("理论结果:" + MAX_TURN * MAX_TREAD);
        System.out.println("实际结果:" + counter.getCount());
        System.out.println("差距是:" + (MAX_TURN * MAX_TREAD - counter.getCount()));
    }

    public static void main(String[] args) {
        try {
            testNotSafePlus();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

}

运行结果:

当前线程执行完成:Thread-0
当前线程执行完成:Thread-4
当前线程执行完成:Thread-5
当前线程执行完成:Thread-3
当前线程执行完成:Thread-7
当前线程执行完成:Thread-2
当前线程执行完成:Thread-1
当前线程执行完成:Thread-9
当前线程执行完成:Thread-8
当前线程执行完成:Thread-6
理论结果:10000
实际结果:5174
差距是:4826

由以上运行结果推出结论:自增运算不是线程安全的。

5.2.synchronized关键字

在Java中,线程同步使用最多的方法是使用synchronized关键字。

每个Java对象都隐含有一把锁,这里称为Java内置锁(或者对象锁、隐式锁)。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。

import java.util.concurrent.CountDownLatch;

public class Test {

    // 线程不安全的累加工具类
    public static class SafePlus {
        private Integer count = 0;

        //变量值自增
        public synchronized void selfPlus() {
            count++;
        }

        // 获取变量值
        public synchronized Integer getCount() {
            return count;
        }
    }

    public static final int MAX_TREAD = 10;
    public static final int MAX_TURN = 1000;

    public static void testSafePlus() throws InterruptedException {
        // 倒数闩,需要倒数MAX_TREAD次
        CountDownLatch latch = new CountDownLatch(MAX_TREAD);
        SafePlus counter = new SafePlus();

        // 线程将 counter 累加 MAX_TURN 次
        Runnable runnable = () -> {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            for (int i = 0; i < MAX_TURN; i++) {
                counter.selfPlus();
            }
            // 倒数闩减少一次
            latch.countDown();
            System.out.println("当前线程执行完成:" + Thread.currentThread().getName());
        };

        // 执行 MAX_TREAD 次 counter 累加
        for (int i = 0; i < MAX_TREAD; i++) {
            new Thread(runnable).start();
        }

        // 等待倒数闩的次数 减少到0,所有的线程执行完成
        latch.await();

        System.out.println("理论结果:" + MAX_TURN * MAX_TREAD);
        System.out.println("实际结果:" + counter.getCount());
        System.out.println("差距是:" + (MAX_TURN * MAX_TREAD - counter.getCount()));
    }

    public static void main(String[] args) {
        try {
            testSafePlus();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

}

个使用synchronized关键字修饰方法以后,运行结果如下:

当前线程执行完成:Thread-0
当前线程执行完成:Thread-2
当前线程执行完成:Thread-5
当前线程执行完成:Thread-3
当前线程执行完成:Thread-1
当前线程执行完成:Thread-9
当前线程执行完成:Thread-7
当前线程执行完成:Thread-6
当前线程执行完成:Thread-8
当前线程执行完成:Thread-4
理论结果:10000
实际结果:10000
差距是:0

结论:在方法声明中设置synchronized同步关键字,保证其方法的代码执行流程是排他性的。任何时间只允许一个线程进入同步方法(临界区代码段),如果其他线程需要执行同一个方法,那么只能等待和排队。

6.参考文献

[1] 百度百科.baike.baidu.com/item/comput…

[2] 尼恩. Java高并发核心编程 卷2:多线程、锁、JMM、JUC、高并发设计模式.2020年5月

[3] www.javatpoint.com/thread-conc…