java并发编程(1)-并发编程基础(上)

164 阅读10分钟

作为一个开发,成长到一定阶段,必不可免的就需要接触并发编程。本篇介绍一些并发编程中的一些基础和概念。

并发与并行

并发通俗上来讲是指在同一个时间段内同时执行多个任务。
并行通俗上来讲是指在同一个时间点内同时执行多个任务。

注意上面的两个概念,也是很多人分不清并发和并行的区别。首先我们明确一个概念,一个时间段是有多个时间点组成。并发是同一个时间段内,并行是同一个时间点内

如果对CPU资源分配略有了解就会知道,CPU资源分配是以时间片为单位。操作系统会通过调度算法决定哪个线程可以获得CPU时间片,获得CPU时间片的线程即可被CPU调度,在这个时间片内占用CPU的一个核,当线程耗尽分配到的时间片,当前线程就会被挂起,然后根据调度算法选择下一个要执行的线程,被选中的线程获得时间片。没有获得CPU时间片的线程就会继续等待下一次分配。
看到这,大家应该已经对并发和并行有个差不多的了解,最后我们举个例子再来说明一下。
假如服务器中有个进程A, 进程A开辟的任务线程数大于CPU核心数,比如说任务线程数是16 核心是8。则理论上来讲最多只有8个线程可以放到不同的CPU核心上运行,使其做到在同一个时间点内同时执行,另外8个线程则需要通过时间片分配才能被调度执行。
套用上面对并发以及并行的定义,进程A在同一时间点内并行执行8个任务,在同一个时间段内并发执行16个任务。注意前提是各个线程资源没有竞争关系,不需要加锁。

上下文切换

上文提到过获得CPU时间片的线程可被CPU调度,时间片耗尽,当前线程被挂起,选择另一个线程被调度。
从一个线程切换到另一个线程或者从一个进程切换到另一个进程,势必还会牵扯到上下文切换,进程和线程的上下文切换的开销也是不同的,在讲解进程跟线程上下文之前,我们应该对进程跟线程两者的定义有个基本的认识。
进程的定义:进程是操作系统资源分配的基本单位,每个进程都有一个主线程
线程的定义:线程是进程的一个执行单元,是操作系统进行调度的基本单位
有了上面对进程和线程定义的铺垫,也能对接下来的进程线程上下文切换的理解有所帮助。

进程上下文切换

上面也讲了进程是操作系统资源分配的基本单位,所以文件和IO资源以及内存在分配时是分配给进程的,因此进程间上下文切换时会发生切换的进程级别的资源有文件描述符号表,页表基址寄存器,进程控制块。
每个进程都有一个主线程,所以进程间上下文切换时还会引起线程级别资源的切换,也就是线程私有资源的切换。在此小节不叙述进程上下文切换时线程级别私有资源的切换,并不代表进程上下文切换仅仅到此为止。只是为了更好理解进程和线程的定义以及它们的上下文切换的资源的区别。

线程上下文切换

在线程的定义中,我们讲过,线程是操作系统进行调度的基本单位,也就是说操作系统在调度时,调度的是线程而不是进程,因此线程上下文切换时切换的资源就已经有点显而易见了。
线程上下文切换需要切换的资源有CPU寄存器,栈指针,内核栈,线程控制块以及定时器和中断。

上下文切换总结

现在我们将上下文切换的内容串起来做个总结。
每个进程都有一个主线程。 所以进程上下文切换时,不止切换进程级别私有的的资源,而且还会切换线程级别私有的资源。大家可以根据这句话以及上文进程上下文切换以及线程上下文切换两个小节,想一下进程上下文切换时发生切换的完整资源有哪些
线程是进程的一个执行单元。 所以线程上下文切换时,不会引起文件描述符号表,页表基址寄存器以及进程控制块这种进程级别私有资源,进程内所有线程共享这些资源。当然前提是线程上下文切换时,只是进程内不同线程发生切换。如果是从一个操作系统中一个进程的线程切换到另外一个进程的线程,实际上和进程上下文切换没什么区别。

死锁

什么是死锁

死锁是指两个或两个以上的线程在运行时,因争夺资源而造成的互相等待的现象,无外力作用下,这些线程会一直等待下去而无法运行下去。
打个比方,就是线程A持有了资源1,线程B持有了资源2,此时线程A想持有资源2,线程B想持有资源1。
这个时候就会出现,线程A因为资源2已被线程B持有,所以线程A阻塞等待线程B释放资源2,而线程B又因为资源1已被线程A持有,所以线程B也阻塞等待线程A释放资源1。线程A等待线程B,线程B又等待线程A,这就是上面所说的死锁。

死锁发生的条件

要发生死锁就必须具备以下四个条件

  1. 互斥条件:线程获取的资源是排他互斥的。即该资源被一个线程获取到后,其他线程无法再获取到该资源,只能等待获取到资源的线程释放该资源后才能再获取该资源。
  2. 请求并持有条件:当前线程已经持有了一个以上的资源,但又请求获取新的资源,而新资源被另一线程持有,所以当前线程会被阻塞,阻塞的同时并不释放自己持有的资源。
  3. 不可剥夺条件:线程获取到资源,在使用完资源前不能被其他资源抢占。只能自己使用完,由自身释放。
  4. 环路等待条件:发生死锁时必定一个环形链。比如说线程0等待线程1持有的资源,线程1等待线程2持有的资源,线程2等待线程0持有的资源。

避免线程死锁

上面也讲了发生死锁必须要具备四个条件,换言之,只要破坏掉至少一个条件,就能避免线程死锁了。
大家仔细分析一下便会发现实际上这4个条件并不是每个条件都可以被破坏掉的。
不可以破坏互斥条件。正因为我们需要在多线程并发情况下保证对数据操作的一致性和准确性才会加锁让这个资源具有互斥性具有排他性,所以不可以破坏互斥条件。
可以破坏请求并持有条件。只要我们能够保持互斥资源的顺序分配就可以做到破坏这个条件,举个例子,线程A和线程B都需要持有资源1和资源2,只要使线程请求持有资源的顺序都是按照先持有资源1,再持有资源2的顺序就可以破坏掉这个条件,因为只有先持有资源1才可以持有资源2,所以不会出现线程A等待线程B持有的资源,线程B等待线程A持有的资源这种情况
不可以破坏不可剥夺条件。不可以破坏的原因也很简单,因为互斥条件是不可以破坏的。有互斥自然伴随着不可剥夺,破坏不可剥夺自然就会破坏掉互斥。
可以破坏环路等待条件。使用资源顺序分配的方法就可以破坏掉环路等待条件。

线程安全问题

在并发编程中,线程安全可以算是重中之重。也许你可以对死锁不用过多了解,因为大部分开发者在并发环境情况下,遇到死锁的场景并不多,但线程安全问题却几乎是所有开发者都会在并发环境下遇到的问题。并发编程是一把双刃剑,熟悉并发编程可以让你写出比别人更高性能的代码,但如果在不熟悉的情况下,盲目使用并发,也会让你的代码出现严重的问题。

线程安全问题的定义

线程安全问题指的是多个线程在没有任何同步措施的情况下对同一个共享资源进行读写操作时,出现的脏数据或者其他不可预见的,与预定的结果不符的问题。

public class Test {

    private static int COUNT = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                COUNT++;
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                COUNT++;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(COUNT);
    }
}

拿上面这段测试代码来更详细的理解什么是线程安全问题。上面的这段java代码整体逻辑很简单,就是对一个静态变量COUNT在多线程并发环境下进行递增操作,然后等待这两个线程结束后打印COUNT结果,按照代码逻辑这个COUNT值应该是20000,但实际运行时出现的结果会是某个小于20000的数值,这里出现了我们没有预见的,与预定结果不符的问题,也就是上文所说的多线程安全问题。
上面这个案例出现多线程安全问题的原因是,我们在对COUNT进行++时,并没有做任何同步措施。而++操作本质上由3个操作组合而成。分别是读取-计算-保存,说详细点,就是执行++时会先将COUNT值读到线程本地,然后执行++对线程本地的COUNT值进行递增操作,最后将递增的结果写回主内存。
所以就会下面说到的情况

  1. 线程1读取主内存的COUNT到线程本地,线程1本地的COUNT值为0
  2. 线程1对本地的COUNT进行++操作,线程1本地的COUNT值为1
  3. 线程2读取主内存的COUNT到线程本地,线程2本地的COUNT值为0
  4. 线程1将本地的COUNT值写回主内存,主内存的COUNT值为1
  5. 线程2对本地的COUNT进行++操作,线程2本地的COUNT值为1
  6. 线程2将本地的COUNT值写回主内存,主内存的COUNT值为1
  7. 对COUNT进行了两次++操作,我们预想的主内存的值应该是2,结果却是1,这就是上面这段代码出现线程安全的原因。

限于篇章问题,并发编程基础的剩余内容我们会在下篇再深入探讨如何去解决线程安全问题。
本篇基于《java并发编程之美》内容,再根据自身理解而写的,或有理解有误之处,所以还是非常推荐大家可以去阅读一下原书。