高并发编程学习(1)——并发基础-LMLPHP

一、前言


当我们使用计算机时,可以同时做许多事情,例如一边打游戏一边听音乐。这是因为操作系统支持并发任务,从而使得这些工作得以同时进行。

  • 那么提出一个问题:如果我们要实现一个程序能一边听音乐一边玩游戏怎么实现呢?
public class Tester {

    public static void main(String[] args) {
        System.out.println("开始....");
        playGame();
        playMusic();
        System.out.println("结束....");
    }

    private static void playGame() {
        for (int i = 0; i < 50; i++) {
            System.out.println("玩游戏" + i);
        }
    }

    private static void playMusic() {
        for (int i = 0; i < 50; i++) {
            System.out.println("播放音乐" + i);
        }
    }
}

我们使用了循环来模拟过程,因为播放音乐和打游戏都是连续的,但是结果却不尽人意,因为函数体总是要执行完之后才能返回。那么到底怎么解决这个问题?

并行与并发

高并发编程学习(1)——并发基础-LMLPHP

并行性和并发性是既相似又有区别的两个概念。

并行性是指两个或多个事件在同一时刻发生。而并发性是指两个或多个事件在同一时间间隔内发生。

在多道程序环境下,并发性是指在一段时间内宏观上有多个程序在同时运行,但在单处理机环境下(一个处理器),每一时刻却仅能有一道程序执行,故微观上这些程序只能是分时地交替执行。例如,在 1 秒钟时间内,0 - 15 ms 程序 A 运行;15 - 30 ms 程序 B 运行;30 - 45 ms 程序 C 运行;45 - 60 ms 程序 D 运行,因此可以说,在 1 秒钟时间间隔内,宏观上有四道程序在同时运行,但微观上,程序 A、B、C、D 是分时地交替执行的。

如果在计算机系统中有多个处理机,这些可以并发执行的程序就可以被分配到多个处理机上,实现并发执行,即利用每个处理机爱处理一个可并发执行的程序。这样,多个程序便可以同时执行。以此就能提高系统中的资源利用率,增加系统的吞吐量。

进程和线程

进程是指一个内存中运行的应用程序。一个应用程序可以同时启动多个进程,那么上面的问题就有了解决的思路:我们启动两个进程,一个用来打游戏,一个用来播放音乐。这当然是一种解决方案,但是想象一下,如果一个应用程序需要执行的任务非常多,例如 LOL 游戏吧,光是需要播放的音乐就有非常多,人物本身的语音,技能的音效,游戏的背景音乐,塔攻击的声音等等等,还不用说游戏本身,就光播放音乐就需要创建许多许多的进程,而进程本身是一种非常消耗资源的东西,这样的设计显然是不合理的。更何况大多数的操作系统都不需要一个进程访问其他进程的内存空间,也就是说,进程之间的通信很不方便,此时我们就得引入“线程”这门技术,来解决这个问题。

线程是指进程中的一个执行任务(控制单元),一个进程可以同时并发运行多个线程。我们可以打开任务管理器,观察到几乎所有的进程都拥有着许多的「线程」(在 WINDOWS 中线程是默认隐藏的,需要在「查看」里面点击「选择列」,有一个线程数的勾选项,找到并勾选就可以了)。

高并发编程学习(1)——并发基础-LMLPHP

进程和线程的区别

进程:有独立的内存空间,进程中的数据存放空间(堆空间和栈空间)是独立的,至少有一个线程。

线程:堆空间是共享的,栈空间是独立的,线程消耗的资源也比进程小,相互之间可以影响的,又称为轻型进程或进程元。

因为一个进程中的多个线程是并发运行的,那么从微观角度上考虑也是有先后顺序的,那么哪个线程执行完全取决于 CPU 调度器(JVM 来调度),程序员是控制不了的。我们可以把多线程并发性看作是多个线程在瞬间抢 CPU 资源,谁抢到资源谁就运行,这也造就了多线程的随机性。下面我们将看到更生动的例子。

Java 程序的进程(Java 的一个程序运行在系统中)里至少包含主线程和垃圾回收线程(后台线程),你可以简单的这样认为,但实际上有四个线程(了解就好):

  • [1] main——main 线程,用户程序入口
  • [2] Reference Handler——清除 Reference 的线程
  • [3] Finalizer——调用对象 finalize 方法的线程
  • [4] Signal Dispatcher——分发处理发送给 JVM 信号的线程

多线程和单线程的区别和联系?

  1. 单核 CPU 中,将 CPU 分为很小的时间片,在每一时刻只能有一个线程在执行,是一种微观上轮流占用 CPU 的机制。

  2. 多线程会存在线程上下文切换,会导致程序执行速度变慢,即采用一个拥有两个线程的进程执行所需要的时间比一个线程的进程执行两次所需要的时间要多一些。

结论:即采用多线程不会提高程序的执行速度,反而会降低速度,但是对于用户来说,可以减少用户的响应时间。

多线程的优势

尽管面临很多挑战,多线程有一些优点仍然使得它一直被使用,而这些优点我们应该了解。

优势一:资源利用率更好

想象一下,一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要 5 秒,处理一个文件需要 2 秒。处理两个文件则需要:

1| 5秒读取文件A
2| 2秒处理文件A
3| 5秒读取文件B
4| 2秒处理文件B
5| ---------------------
6| 总共需要14秒

从磁盘中读取文件的时候,大部分的 CPU 时间用于等待磁盘去读取数据。在这段时间里,CPU 非常的空闲。它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用 CPU 资源。看下面的顺序:

1| 5秒读取文件A
2| 5秒读取文件B + 2秒处理文件A
3| 2秒处理文件B
4| ---------------------
5| 总共需要12秒

CPU 等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU 会去处理第一个文件。记住,在等待磁盘读取文件的时候,CPU 大部分时间是空闲的。

总的说来,CPU 能够在等待 IO 的时候做一些其他的事情。这个不一定就是磁盘 IO。它也可以是网络的 IO,或者用户输入。通常情况下,网络和磁盘的 IO 比 CPU 和内存的 IO 慢的多。

优势二:程序设计在某些情况下更简单

在单线程应用程序中,如果你想编写程序手动处理上面所提到的读取和处理的顺序,你必须记录每个文件读取和处理的状态。相反,你可以启动两个线程,每个线程处理一个文件的读取和操作。线程会在等待磁盘读取文件的过程中被阻塞。在等待的时候,其他的线程能够使用 CPU 去处理已经读取完的文件。其结果就是,磁盘总是在繁忙地读取不同的文件到内存中。这会带来磁盘和 CPU 利用率的提升。而且每个线程只需要记录一个文件,因此这种方式也很容易编程实现。

优势三:程序响应更快

有时我们会编写一些较为复杂的代码(这里的复杂不是说复杂的算法,而是复杂的业务逻辑),例如,一笔订单的创建,它包括插入订单数据、生成订单赶快找、发送邮件通知卖家和记录货品销售数量等。用户从单击“订购”按钮开始,就要等待这些操作全部完成才能看到订购成功的结果。但是这么多业务操作,如何能够让其更快地完成呢?

在上面的场景中,可以使用多线程技术,即将数据一致性不强的操作派发给其他线程处理(也可以使用消息队列),如生成订单快照、发送邮件等。这样做的好处是响应用户请求的线程能够尽可能快地处理完成,缩短了响应时间,提升了用户体验。

其他优势

多线程还有一些优势也显而易见:

  • 进程之前不能共享内存,而线程之间共享内存(堆内存)则很简单。
  • 系统创建进程时需要为该进程重新分配系统资源,创建线程则代价小很多,因此实现多任务并发时,多线程效率更高.
  • Java 语言本身内置多线程功能的支持,而不是单纯地作为底层系统的调度方式,从而简化了多线程编程.

上下文切换

即使是单核处理器也支持多线程执行代码,CPU 通过给每个线程分配 CPU 时间片来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。

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

二、创建线程的两种方式


继承 Thread 类

public class Tester {

    // 播放音乐的线程类
    static class PlayMusicThread extends Thread {

        // 播放时间,用循环来模拟播放的过程
        private int playTime = 50;

        public void run() {
            for (int i = 0; i < playTime; i++) {
                System.out.println("播放音乐" + i);
            }
        }
    }

    // 方式1:继承 Thread 类
    public static void main(String[] args) {
        // 主线程:运行游戏
        for (int i = 0; i < 50; i++) {
            System.out.println("打游戏" + i);
            if (i == 10) {
                // 创建播放音乐线程
                PlayMusicThread musicThread = new PlayMusicThread();
                musicThread.start();
            }
        }
    }
}

运行结果发现打游戏和播放音乐交替出现,说明已经成功了。

实现 Runnable 接口

public class Tester {

    // 播放音乐的线程类
    static class PlayMusicThread implements Runnable {

        // 播放时间,用循环来模拟播放的过程
        private int playTime = 50;

        public void run() {
            for (int i = 0; i < playTime; i++) {
                System.out.println("播放音乐" + i);
            }
        }
    }

    // 方式2:实现 Runnable 方法
    public static void main(String[] args) {
        // 主线程:运行游戏
        for (int i = 0; i < 50; i++) {
            System.out.println("打游戏" + i);
            if (i == 10) {
                // 创建播放音乐线程
                Thread musicThread = new Thread(new PlayMusicThread());
                musicThread.start();
            }
        }
    }
}

也能完成效果。

以上就是传统的两种创建线程的方式,事实上还有第三种,我们后边再讲。

多线程一定快吗?

先来一段代码,通过并行和串行来分别执行累加操作,分析:下面的代码并发执行一定比串行执行快吗?

import org.springframework.util.StopWatch;

// 比较并行和串行执行累加操作的速度
public class Tester {

    // 执行次数
    private static final long COUNT = 100000000;
    private static final StopWatch TIMER = new StopWatch();

    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
        // 打印比较测试结果
        System.out.println(TIMER.prettyPrint());
    }

    private static void serial() {
        TIMER.start("串行执行" + COUNT + "条数据");

        int a = 0;
        for (long i = 0; i < COUNT; i++) {
            a += 5;
        }
        // 串行执行
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }

        TIMER.stop();
    }

    private static void concurrency() throws InterruptedException {
        TIMER.start("并行执行" + COUNT + "条数据");

        // 通过匿名内部类来创建线程
        Thread thread = new Thread(() -> {
            int a = 0;
            for (long i = 0; i < COUNT; i++) {
                a += 5;
            }
        });
        thread.start();

        // 并行执行
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }
        // 等待线程结束
        thread.join();
        TIMER.stop();
    }
}

大家可以自己测试一下,每一台机器 CPU 不同测试结果可能也会不同,之前在 WINDOWS 本儿上测试的时候,多线程的优势从 1 千万数据的时候才开始体现出来,但是现在换了 MAC,1 亿条数据时间也差不多,到 10 亿的时候明显串行就比并行快了... 总之,为什么并发执行的速度会比串行慢呢?就是因为线程有创建和上下文切换的开销。

继承 Thread 类还是实现 Runnable 接口?

想象一个这样的例子:给出一共 50 个苹果,让三个同学一起来吃,并且给苹果编上号码,让他们吃的时候顺便要说出苹果的编号:

高并发编程学习(1)——并发基础-LMLPHP

运行结果可以看到,使用继承方式实现,每一个线程都吃了 50 个苹果。这样的结果显而易见:是因为显式地创建了三个不同的 Person 对象,而每个对象在堆空间中有独立的区域来保存定义好的 50 个苹果。

而使用实现方式则满足要求,这是因为三个线程共享了同一个 Apple 对象,而对象中的 num 数量是一定的。

所以可以简单总结出继承方式和实现方式的区别:

继承方式:

  1. Java 中类是单继承的,如果继承了 Thread 了,该类就不能再有其他的直接父类了;
  2. 从操作上分析,继承方式更简单,获取线程名字也简单..(操作上,更简单)
  3. 从多线程共享同一个资源上分析,继承方式不能做到...

实现方式:

  1. Java 中类可以实现多个接口,此时该类还可以继承其他类,并且还可以实现其他接口(设计上,更优雅)..
  2. 从操作上分析,实现方式稍微复杂点,获取线程名字也比较复杂,需要使用 Thread.currentThread() 来获取当前线程的引用..
  3. 从多线程共享同一个资源上分析,实现方式可以做到..

在这里,三个同学完成抢苹果的例子,使用实现方式才是更合理的方式。

对于这两种方式哪种好并没有一个确定的答案,它们都能满足要求。就我个人意见,我更倾向于实现 Runnable 接口这种方法。因为线程池可以有效的管理实现了 Runnable 接口的线程,如果线程池满了,新的线程就会排队等候执行,直到线程池空闲出来为止。而如果线程是通过实现 Thread 子类实现的,这将会复杂一些。

有时我们要同时融合实现 Runnable 接口和 Thread 子类两种方式。例如,实现了 Thread 子类的实例可以执行多个实现了 Runnable 接口的线程。一个典型的应用就是线程池。

常见错误:调用 run() 方法而非 start() 方法

创建并运行一个线程所犯的常见错误是调用线程的 run() 方法而非 start() 方法,如下所示:

1| Thread newThread = new Thread(MyRunnable());
2| newThread.run();  //should be start();

起初你并不会感觉到有什么不妥,因为 run() 方法的确如你所愿的被调用了。但是,事实上,run() 方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。也就是被执行上面两行代码的线程所执行的。想要让创建的新线程执行 run() 方法,必须调用新线程的 start() 方法。

三、线程的安全问题


吃苹果游戏的不安全问题

我们来考虑一下上面吃苹果的例子,会有什么问题?

尽管,Java 并不保证线程的顺序执行,具有随机性,但吃苹果比赛的案例运行多次也并没有发现什么太大的问题。这并不是因为程序没有问题,而只是问题出现的不够明显,为了让问题更加明显,我们使用 Thread.sleep() 方法(经常用来模拟网络延迟)来让线程休息 10 ms,让其他线程去抢资源。(注意:在程序中并不是使用 Thread.sleep(10)之后,程序才出现问题,而是使用之后,问题更明显.)

高并发编程学习(1)——并发基础-LMLPHP

为什么会出现这样的错误呢?

先来分析第一种错误:为什么会吃重复的苹果呢?就拿 B 和 C 都吃了编号为 47 的苹果为例吧:

再来分析第二种错误:照理来说只应该存在 1-50 编号的苹果,可是 0 和-1 是怎么出现的呢?

归根结底是因为没有任何操作来限制线程来获取相同的资源并对他们进行操作,这就造成了线程安全性问题。

像这样的原子操作,是不允许分步骤进行的,必须保证同步进行,不然可能会引发不可设想的后果。

要解决上述多线程并发访问一个资源的安全性问题,就需要引入线程同步的概念。

线程同步

多个执行线程共享一个资源的情景,是最常见的并发编程情景之一。为了解决访问共享资源错误或数据不一致的问题,人们引入了临界区的概念:用以访问共享资源的代码块,这个代码块在同一时间内只允许一个线程执行。

为了帮助编程人员实现这个临界区,Java(以及大多数编程语言)提供了同步机制,当一个线程试图访问一个临界区时,它将使用一种同步机制来查看是不是已经有其他线程进入临界区。如果没有其他线程进入临界区,他就可以进入临界区。如果已经有线程进入了临界区,它就被同步机制挂起,直到进入的线程离开这个临界区。如果在等待进入临界区的线程不止一个,JVM 会选择其中的一个,其余的将继续等待。

synchronized 关键字

如果一个对象已用 synchronized 关键字声明,那么只有一个执行线程被允许访问它。使用 synchronized 的好处显而易见:保证了多线程并发访问时的同步操作,避免线程的安全性问题。但是坏处是:使用 synchronized 的方法/代码块的性能比不用要低一些。所以好的做法是:尽量减小 synchronized 的作用域。

我们还是先来解决吃苹果的问题,考虑一下 synchronized 关键字应该加在哪里呢?

高并发编程学习(1)——并发基础-LMLPHP

发现如果还再把 synchronized 关键字加在 if 里面的话,0 和 -1 又会出来了。这其实是因为当 ABC 同是进入到 if 语句中,等待临界区释放的时,拿到 1 编号的线程已经又把 num 减一操作了,而此时最后一个等待临界区的进程拿到的就会是 -1 了。

同步锁 Lock

Lock 机制提供了比 synchronized 代码块和 synchronized 方法更广泛的锁定操作,同步代码块/ 同步方法具有的功能 Lock 都有,除此之外更强大,更体现面向对象。在并发包的类族中,Lock 是 JUC 包的顶层接口,它的实现逻辑并未用到 synchronized,而是利用了 volatile 的可见性。

使用 Lock 最典型的代码如下:

class X {

    private final ReentrantLock lock = new ReentrantLock();

    public void m() {
        lock.lock();
        try {
            // ..... method body
        } finally {
            lock.unlock();
        }
    }
}

线程安全问题

线程安全问题只在多线程环境下才会出现,单线程串行执行不存在此类问题。保证高并发场景下的线程安全,可以从以下四个维度考量:

维度一:数据单线程可见

单线程总是安全的。通过限制数据仅在单线程内可见,可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。TreadLocal 就是采用这种方式来实现线程安全的。

维度二:只读对象

只读对象总是安全的。它的特性是允许复制、拒绝写入。最典型的只读对象有 String、Integer 等。一个对象想要拒绝任何写入,必须要满足以下条件:

  • 使用 final 关键字修饰类,避免被继承;
  • 使用 private final 关键字避免属性被中途修改;
  • 没有任何更新方法;
  • 返回值不能为可变对象。

维度三:线程安全类

某些线程安全类的内部有非常明确的线程安全机制。比如 StringBuffer 就是一个线程安全类,它采用 synchronized 关键字来修饰相关方法。

维度四:同步与锁机制

如果想要对某个对象进行并发更新操作,但又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。虽然这个机制支持的并发场景很有价值,但非常复杂且容易出现问题。

处理线程安全的核心理念

要么只读,要么加锁。

合理利用好 JDK 提供的并发包,往往能化腐朽为神奇。Java 并发包(java.util.concurrent,JUC)中大多数类注释都写有:@author Doug Lea。如果说 Java 是一本史书,那么 Doug Lea 绝对是开疆拓土的伟大人物。Doug Lea 在当大学老师时,专攻并发编程和并发数据结构设计,主导设计了 JUC 并发包,提高了 Java 并发编程的易用性,大大推进了 Java 的商用进程。

参考资料



按照惯例黏一个尾巴:

11-27 12:00