前言

在面试过程中,多线程编程也是一个必考的知识点。就像是面试时问你,sleep()wait()两个函数有什么区别一样。

思考

  1. 什么是多线程编程?
  2. 为什么要多线程编程?
  3. 如何多线程编程?

基础知识

阅读本文的读者,应该都上过操作系统的课程。而进程和线程就是操作系统的知识点之一。就如同现代的pc一般,从18年开始基础款笔记本基本上都已经使用上了四核八线程的芯片了。但是和这个实际意义上的芯片不同,我们讲的编程中使用到的线程是需要类似映射的过程慢慢的转换然后交给芯片处理的。

先上一张图,让读者清晰的了解什么叫做进程,什么叫做线程。
活动监视器

这是Mac的活动监视器,Windows要访问的话打开任务管理器即可。从图就很清晰的看出来什么是进程了。而线程包含在进程中。

用概念型的语言描述。

  1. 进程:程序在一个数据集合上的运行过程,是线程的容器。
  2. 线程:轻量级进程。

从定义中也算很明显的能够感知了,为什么我们的编程要叫多线程,而不叫做多进程。因为它轻啊,朋友们。

下面再送读者们两张图片。
进程状态转化图

进程转化图中标示了4个值用一句话来记忆比较有效。
进程因创建而产生,因调度而执行,因得不到资源而阻塞,因得不到资源而阻塞,因撤销而消亡。
图中代表的4个值:
(1) 得到CPU的时间片 / 调度。
(2) 时间片用完,等待下一个时间片。
(3) 等待 I/O 操作 / 等待事件发生。
(4) I/O操作结束 / 事件完成。

虽然图示和需要记忆的句子其实相同,只是省去了创建和消亡的状态。

线程状态转化图

这张图其实对应的是Java线程运行时声明的6种状态,状态转化以及调用到的函数,也都清晰的呈现在图中了。
那为什么要多线程编程?其实他包揽了很多事情,就拿知乎这个app举例子,我们之前讲过UI线程,也就是一般我们写程序时的main()函数。他如果只请求一个列表信息,那我们的UI线程可能还忙的过来,但是他如果要同时请求用户信息,多个列表信息呢?他要忙到猴年马月,这就是线程的作用,每个异步处理一个任务,然后返回结果,就比较成功的完成了这个任务。

如何多进程编程

先写两种非常简单的线程使用方法。

// 1
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("this is a Runnable");
    }
}
// 2
public class MyThread extends Thread {
    @Override
    public void run() {
        super.run();
        System.out.println("this is thread");
    }
}

// 具体使用
public class Main {
    public static void main(String[] args) {
        // 第一种
        Thread thread1 = new Thread(new MyRunnable());
        thread1.start();
        // 第二种
        MyThread thread2 = new MyThread();
        thread2.start();
    }
}

两种方法效果相同,不过一般来说推荐的是使用第一种,也就是重写Runnable

多线程固然好,但是如果出现下面的情况,你还会愿意继续多线程编程吗?

public class Main {
    public int i = 0;
    public void increase(){
        I++;
    }

    public static void main(String[] args) {
        final Main main = new Main();
        for(int i=0; i< 10; i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0; j<1000; j++){
                        main.increase();
                    }
                }
            }).start();
        }
        while(Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(main.i);
    }
}

上述结果只是通过一个自加操作得出的结果,因为两个线程互不干扰,但是当他们同时对一个公共数据进行操作的时候,就会出现读脏数据等现象,这个时候我们就需要引入同步机制。

同步

一般情况下,我们会通过两种方式来实现。

  1. synchronized
  2. Lock

在操作系统中,有这么一个概念,叫做临界区。其实就是同一时间只能允许存在一个任务访问的代码区间。代码模版如下:

Lock lock = new ReentrantLock();
public void lockModel(){
    lock.lock();
    // 用于书写共同代码,比如说卖同一辆动车的车票等等。

    lock.unlock();
}

// 上述模版等价于下面的函数
public synchronized void lockModel(){}

其实这就是大家常说的锁机制,通过加锁和解锁的方法,来保证数据的正确性。

但是锁的开销还是我们需要考虑的范畴,在不太必要时,我们更长会使用是volatile关键词来修饰变量,来保证数据的准确性。

Java内存模型

对上述的共享变量内存而言,如果线程A和B之间要通信,则必须先更新主内存中的共享变量,然后由另外一个线程去主内存中去读取。但是普通变量一般是不可见的。而volatile关键词就将这件事情变成了可能。
打个比方,共享变量如果使用了volatile关键词,这个时候线程B改变了共享变量副本,线程A就能够感知到,然后经历上述的通信步骤。
这个时候就保障了可见性。
但是另外两种特性,也就是有序性和原子性中,原子性是无法保障的。
拿最开始的Main的类做例子,就只改变一个变量。

public volatile int i = 0;

和上面的代码一样,每次运行的结果都不会相同。

所以一般关于volatile有以下两种用法。

  1. 状态标志
    因为只会使用truefalse,自然也就满足了原子操作。
volatile boolean flag = false;
void doSomething(){
    flag = true;
}

void check(){
    if(flag){
        // ···········
    }
}
  1. 双重检查模式 / DCL

    详见设计模式(二)