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

为更良好的阅读体验,请访问原文: 传送门

1、前言


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

  • 那么提出一个问题:若是咱们要实现一个程序能一边听音乐一边玩游戏怎么实现呢?
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);
        }
    }
}

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

并行与并发

并行性和并发性是既类似又有区别的两个概念。程序员

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

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

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

进程和线程

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

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

进程和线程的区别

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

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

由于一个进程中的多个线程是并发运行的,那么从微观角度上考虑也是有前后顺序的,那么哪一个线程执行彻底取决于 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 经过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。可是,在切换前会保存上一个任务的状态,以便下次切换回这个任务的时候,能够再加载这个任务的状态。因此任务从保存到再加载的过程就是一次上下文切换。

这就像咱们同时读两本书,当咱们在读一本英文的技术书时,发现某个单词不认识,因而打开中英文字典,可是在放下英文技术书以前,大脑必须先记住这本书独到了多少页的多少行,等查完单词以后,可以继续读这本书。这样的切换是会影响读书效率的,一样上下文切换也会影响多线程的执行速度。

2、建立线程的两种方式


继承 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 个苹果,让三个同窗一块儿来吃,而且给苹果编上号码,让他们吃的时候顺便要说出苹果的编号:

运行结果能够看到,使用继承方式实现,每个线程都吃了 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() 方法。

3、线程的安全问题


吃苹果游戏的不安全问题

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

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

为何会出现这样的错误呢?

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

  • A 线程拿到了编号为 48 的苹果,打印输出而后让 num 减 1,睡眠 10 ms,此时 num 为 47。
  • 这时 B 和 C 同时都拿到了编号为 47 的苹果,打印输出,在其中一个线程做出了减一操做的时候,A 线程从睡眠中醒过来,拿到了编号为 46 的苹果,而后输出。在这期间并无任何操做不容许 B 和 C 线程不能拿到同一个编号的苹果,以前没有明显的错误仅仅可能只是由于运行速度太快了。

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

  • 当 num = 1 的时候,A,B,C 三个线程同时进入了 try 语句进行睡眠。
  • C 线程先醒过来,输出了编号为 1 的苹果,而后让 num 减一,当 C 线程醒过来的时候发现 num 为 0 了。
  • A 线程醒过来一看,0 都没有了,只有 -1 了。

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

若是咱们把打印和减一的操做分红两个步骤,会更加明显:

ABC 三个线程同时打印了 50 的苹果,而后同时作出减一操做。

像这样的原子操做,是不容许分步骤进行的,必须保证同步进行,否则可能会引起不可设想的后果。

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

线程同步

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

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

synchronized 关键字

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

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

发现若是还再把 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 的商用进程。

参考资料



按照惯例黏一个尾巴:

欢迎转载,转载请注明出处!
独立域名博客:wmyskxz.com
简书 ID: @我没有三颗心脏
github: wmyskxz
欢迎关注公众微信号:wmyskxz
分享本身的学习 & 学习资料 & 生活
想要交流的朋友也能够加 qq 群:3382693

本文由博客一文多发平台 OpenWrite 发布!

相关文章
相关标签/搜索