并发编程之:线程

大家好,我是小黑,一个在互联网苟且偷生的农民工。前段时间公司面试招人,发现好多小伙伴虽然已经有两三年的工作经验,但是对于一些Java基础的知识掌握的都不是很扎实,所以小黑决定开始跟大家分享一些Java基础相关的内容。首先这一期我们从Java的多线程开始。

好了,接下来进入正题,先来看看什么是进程和线程。

进程VS线程

进程是计算机操作系统中的一个线程集合,是系统资源调度的基本单位,正在运行的一个程序,比如QQ,微信,音乐播放器等,在一个进程中至少包含一个线程。

线程是计算机操作系统中能够进行运算调度的最小单位。一条线程实际上就是一段单一顺序运行的代码。比如我们音乐播放器中的字幕展示,和声音的播放,就是两个独立运行的线程。

并发编程之:线程

了解完进程和线程的区别,我们再来看一下并发和并行的概念。

并发VS并行

当有多个线程在操作时,如果系统只有一个CPU,假设这个CPU只有一个内核,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。这种方式我们称之为并发(Concurrent)。

当系统有一个以上CPU或者一个CPU有多个内核时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

读完上面这段话,是不是感觉好像懂了,又好像没懂?啥并发?啥并行?马什么梅?什么冬梅?

别着急,小黑先给大家用个通俗的例子解释一下并发和并行的区别,然后再看上面这段话,相信大家就都能够理解了。

你吃饭吃到一半,电话来了,你一直把饭吃完之后再去接电话,这就说明你不支持并发也不支持并行;

你吃饭吃到一半,电话来了,你去电话,然后吃一口饭,接一句电话,吃一口饭,接一句电话,这就说明你支持并发;

你吃饭吃到一半,电话来了,你妹接电话,你在一直吃饭,你妹在接电话,这就叫并行。

总结一下,并发的关键,是看你有没有处理多个任务的能力,不是同时处理;

并行的关键是看能不能同时处理多个任务,那要想处理多个任务,就要有“你妹”(另一个CPU或者内核)的存在(怎么感觉好像在骂人)。

Java中的线程

在Java作为一门高级计算机语言,同样也有进程和线程的概念。

我们用Main方法启动一个Java程序,其实就是启动了一个Java进程,在这个进程中至少包含2个线程,另一个是用来做垃圾回收的GC线程。

Java中通常通过Thread类来创建线程,接下来我们看看具体是如何来做的。

线程的创建方式

要想在Java代码中要想自定义一个线程,可以通过继承Thread类,然后创建自定义个类的对象,调用该对象的start()方法来启动。

public class ThreadDemo {
    public static void main(String[] args) {
        new MyThread().start();
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("这是我自定义的线程");
    }
}

或者实现java.lang.Runnable接口,在创建Thread类的对象时,将自定义java.lang.Runnable接口的实例对象作为参数传给Thread,然后调用start()方法启动。

public class ThreadDemo {
    public static void main(String[] args) {
        new Thread(new MyRunnable()).s
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("这是我自定义的线程");
    }
}

那在实际开发过程中,是创建Thread的子类,还是实现Runnable接口呢?其实并没有一个确定的答案,我个人更喜欢实现Runnable接口这种用法。在以后要学的线程池中也是对于Runnable接口的实例进行管理。当然我们也要根据实际场景灵活变通。

线程的启动和停止

从上面的代码中我们其实已经看到,创建线程之后通过调用start()方法就可以实现线程的启动。

new MyThread().start();

注意,我们看到从上一节的代码中看到我们自定义的Thread类是重写了父类的run()方法,那我们直接调用run()方法可不可以启动一个线程呢?答案是不可以。直接调用run()方法和普通的方法调用没有区别,不会开启一个新线程执行,这里一定要注意。

那要怎么来停止一个线程呢?我们看Thread类的方法,是有一个stop()方法的。

@Deprecated // 已经弃用了。
public final void stop() {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        checkAccess();
        if (this != Thread.currentThread()) {
            security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
        }
    }
    if (threadStatus != 0) {
        resume(); 
    }
    stop0(new ThreadDeath());
}

但是我们从这个方法上可以看到是加了@Deprecated注解的,也就是这个方法被JDK弃用了。被弃用的原因是因为通过stop()方法会强制让这个线程停止,这对于线程中正在运行的程序是不安全的,就好比你正在拉屎,别人强制不让你拉了,这个时候你是夹断还是不夹断(这个例子有点恶心,但是很形象哈哈)。所以在需要停止形成的是不不能使用stop方法。

那我们应该怎样合理地让一个线程停止呢,主要有以下2种方法:

第一种:使用标志位终止线程

class MyRunnable implements Runnable {
    private volatile boolean exit = false; // volatile关键字,保证主线程修改后当前线程能够看到被改后的值(可见性)
    @Override
    public void run() {
        while (!exit) { // 循环判断标识位,是否需要退出
            System.out.println("这是我自定义的线程");
        }
    }
    public void setExit(boolean exit) {
        this.exit = exit;
    }
}
public class ThreadDemo {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        new Thread(runnable).start();
        runnable.setExit(true); //修改标志位,退出线程
    }
}

在线程中定义一个标志位,通过判断标志位的值决定是否继续执行,在主线程中通过修改标志位的值达到让线程停止的目的。

第二种:使用interrupt()中断线程

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable runnable = new MyRunnable();
        Thread t = new Thread(runnable);
        t.start();
        Thread.sleep(10);
        t.interrupt(); // 企图让线程中断
    }
}
class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            System.out.println("线程正在执行~" + i);
        }
    }
}

这里需要注意的点,就是interrupt()方法并不会像使用标志位或者stop()方法一样,让线程马上停止,如果你运行上面这段代码会发现,线程t并不会被中断。那么如何才能让线程t停止呢?这个时候就要关注Thread类的另外两个方法。

public static boolean interrupted(); // 判断是否被中断,并清除当前中断状态
private native boolean isInterrupted(boolean ClearInterrupted); // 判断是否被中断,通过ClearInterrupted决定是否清楚中断状态

那么我们再来修改一下上面的代码。

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable runnable = new MyRunnable();
        Thread t = new Thread(runnable);
        t.start();
        Thread.sleep(10);
        t.interrupt();
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100000; i++) {
            //if (Thread.currentThread().isInterrupted()) {
            if (Thread.interrupted()) {
                break;
            }
            System.out.println("线程正在执行~" + i);
        }
    }
}

这个时候线程t就会被中断执行。

到这里大家其实会有个疑惑,这种方式和上面的通过标志位的方式好像没有什么区别呀,都是判断一个状态,然后决定要不要结束执行,它们俩到底有啥区别呢?这里其实就涉及到另一个东西叫做线程状态,如果当线程t在sleep()或者wait()的时候,如果用标识位的方式,其实并不能立马让线程中断,只能等sleep()结束或者wait()被唤醒之后才能中断。但是用第二种方式,在线程休眠时,如果调用interrupt()方法,那么就会抛出一个异常InterruptedException,然后线程继续执行。

线程的状态

通过上面对于线程停止方法的对比,我们了解到线程除了运行和停止这两种状态意外,还有wait(),sleep()这样的方法,可以让线程进入到等待或者休眠的状态,那么线程具体都哪些状态呢?其实通过代码我们能够找到一些答案。在Thread类中有一个叫State的枚举类,这个枚举类中定义了线程的6中状态。

public enum State {
    /**
     * 尚未启动的线程的线程状态
     */
    NEW,
    /**
     * 可运行状态
     */
    RUNNABLE,
    /**
     * 阻塞状态
     */
    BLOCKED,
    /**
     * 等待状态
     */
    WAITING,
    /**
     * 超时等待状态
     */
    TIMED_WAITING,
    /**
     * 终止状态
     */
    TERMINATED;
}

那么线程中的这六种状态到底是怎么变化的呢?什么时候时RUNNABLE,什么时候BLOCKED,我们通过下面的图来展示线程见状态发生变化的情况。

并发编程之:线程

线程状态详细说明

初始化状态(NEW)

在一个Thread实例被new出来时,这个线程对象的状态就是初始化(NEW)状态。

可运行状态(RUNNABLE)

  1. 在调用start()方法后,这个线程就到达可运行状态,注意,可运行状态并不代表一定在运行,因为操作系统的CPU资源要轮换执行(也就是最开始说的并发),要等操作系统调度,只有被调度到才会开始执行,所以这里只是到达就绪(READY)状态,说明有资格被系统调度;
  2. 当系统调度本线程之后,本线程会到达运行中(RUNNING)状态,在这个状态如果本线程获取到的CPU时间片用完以后,或者调用yield()方法,会重新进入到就绪状态,等待下一次被调度;
  3. 当某个休眠线程被notify(),会进入到就绪状态;
  4. 被park(Thread)的线程又被unpark(Thread),会进入到就绪状态;
  5. 超时等待的线程时间到时,会进入到就绪状态;
  6. 同步代码块或同步方法获取到锁资源时,会进入到就绪状态;

超时等待(TIMED_WAITING)

当线程调用sleep(long),join(long)等方法,或者同步代码中锁对象调用wait(long),以及LockSupport.arkNanos(long),LockSupport.parkUntil(long)这些方法都会让线程进入超时等待状态。

等待(WAITING)

等待状态和超时等待状态的区别主要是没有指定等待多长的时间,像Thread.join(),锁对象调用wait(),LockSupport.park()等这些方法会让线程进入等待状态。

阻塞(BLOCKED)

阻塞状态主要发生在获取某些资源时,在获取成功之前,会进入阻塞状态,知道获取成功以后,才会进入可运行状态中的就绪状态。

终止(TERMINATED)

终止状态很好理解,就是当前线程执行结束,这个时候就进入终止状态。这个时候这个线程对象也许是存活的,但是没有办法让它再去执行。所谓“线程”死不能复生。

线程重要的方法

从上一节我们看到线程状态之间变化会有很多方法的调用,像Join(),yield(),wait(),notify(),notifyAll(),这么多方法,具体都是什么作用,我们来看一下。

上面我们讲到过的start()、run()、interrupt()、isInterrupted()、interrupted()这些方法想必都已经理解了,这里不做过多的赘述。

/**
 * sleep()方法是让当前线程休眠若干时间,它会抛出一个InterruptedException中断异常。
 * 这个异常不是运行时异常,必须捕获且处理,当线程在sleep()休眠时,如果被中断,这个异常就会产生。
 * 一旦被中断后,抛出异常,会清除标记位,如果不加处理,下一次循环开始时,就无法捕获这个中断,故一般在异常处理时再设置标记位。
 * sleep()方法不会释放任何对象的锁资源。
 */
public static native void sleep(long millis) throws InterruptedException;

/**
 * yield()方法是个静态方法,一旦执行,他会使当前线程让出CPU。让出CPU不代表当前线程不执行了,还会进行CPU资源的争夺。
 * 如果一个线程不重要或优先级比较低,可以用这个方法,把资源给重要的线程去做。
 */
public static native void yield();
/**
 * join()方法表示无限的等待,他会一直阻塞当前线程,只到目标线程执行完毕。
 */
public final void join() throws InterruptedException ;
/**
 * join(long millis) 给出了一个最大等待时间,如果超过给定的时间目标线程还在执行,当前线程就不等了,继续往下执行。
 */
public final synchronized void join(long millis) throws InterruptedException ;

以上这些方法是Thread类中的方法,从方法签名可以看出,sleep()和yield()方法是静态方法,而join()方法是成员方法。

而wait(),notify(),notifyAll()这三个方式是Object类中的方法,这三个方法主要用于在同步方法或同步代码块中,用于对共享资源有竞争的线程之间的通信。

/**
 * 使当前线程等待,直到另一个线程调用该对象的 notify()方法或 notifyAll()方法。
 */
public final void wait() throws InterruptedException
/**
 * 唤醒正在等待对象监视器的单个线程。
 */
public final native void notify();
/**
 * 唤醒正在等待对象监视器的所有线程。
 */
public final native void notifyAll();

针对wait(),notify/notifyAll() 有一个典型的案例:生产者消费者,通过这个案例能加深大家对于这三个方法的印象。

场景如下:

假设现在有一个KFC(KFC给你多少钱,我金拱门出双倍),里面有汉堡在销售,为了汉堡的新鲜呢,店员在制作时最多不会制作超过10个,然后会有顾客来购买汉堡。当汉堡数量到10个时,店员要停止制作,而当数量等于0也就是卖完了的时候,顾客得等新汉堡制作处理。

我们现在通过两个线程一个来制作,一个来购买,来模拟这个场景。代码如下:

class KFC {
    // 汉堡数量
    int hamburgerNum = 0; 
    
    public void product() {
        synchronized (this) {
            while (hamburgerNum == 10) {
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("生产一个汉堡" + (++hamburgerNum));
            this.notifyAll();
        }
    }
    
    public void consumer() {
        synchronized (this) {
            while (hamburgerNum == 0) {
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("卖出一个汉堡" + (hamburgerNum--));
            this.notifyAll();
        }
    }
}
public class ProdConsDemo {
    public static void main(String[] args) {
        KFC kfc = new KFC();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.product();
            }
        }, "店员").start();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.consumer();
            }
        }, "顾客").start();

    }
}

从上面的代码可以看出,这三个方法是要配合使用的。

wait()、notify/notifyAll() 方法是Object的本地final方法,无法被重写。

wait()使当前线程阻塞,前提是必须先获得锁,一般配合synchronized关键字使用。

当线程执行wait()方法时,会释放当前的锁,然后让出CPU,进入等待状态。

由于 wait()、notify/notifyAll() 在synchronized 代码块执行,说明当前线程一定是获取了锁的。只有当notify/notifyAll()被执行时,才会唤醒一个或多个正处于等待状态的线程,然后继续往下执行,直到执行完synchronized代码块的代码或是中途遇到wait() ,再次释放锁。

要注意,notify/notifyAll()唤醒沉睡的线程后,线程会接着上次的执行继续往下执行。所以在进行条件判断时候,不能使用if来判断,假设存在多个顾客来购买,当被唤醒之后如果不做判断直接去买,有可能已经被另一个顾客买完了,所以一定要用while判断,在被唤醒之后重新进行一次判断。

最后再强调一下wait()和我们上面讲到的sleep()的区别,sleep()可以随时随地执行,不一定在同步代码块中,所以在同步代码块中调用也不会释放锁,而wait()方法的调用必须是在同步代码中,并且会释放锁。


好了,今天的内容就到这里。我是小黑,我们下期见。
如果喜欢小黑也可以关注我的微信公众号黑子的学习笔记,全网同名。
并发编程之:线程

并发编程之:线程

上一篇:【数据结构】排序——外部排序(1)


下一篇:Go语言 基础知识(一)