Java 并发编程基础 ① - 线程

Richard_Yi 2020年03月14日 247次浏览

一、什么是线程

进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源

操作系统在分配资源时是把资源分配给进程的,但是CPU资源比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以也说线程是CPU分配的基本单位

以Java 为例,我们启动一个main函数时,实际上就是启动了一个JVM 的进程main函数所在的线程就是这个进程的一个线程,也称为主线程。一个JVM进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域

二、线程创建和运行

Java 线程创建有3种方式:

  1. 继承 Thread 类并且重写 run 方法

  2. 实现 Runnable接口的 run 方法

  3. 使用FutureTask方式

具体代码示例不详述,太基础,会感觉在水。

说下 FutureTask 的方式,这种方式的本事也是实现了Runnable 接口的 run 方法,看它的继承结构就可以知道。

前两种方式都没办法拿到任务的返回结果,但是 Futuretask 方式可以。

三、线程通知与等待

3.1 wait() 方法

wait() 方法的效果就是该调用线程被阻塞挂起,直到发生以下几种情况才会调起:

  • 其他线程调用了该共享对象的 notify() 方法或者 notifyAll() 方法(继续往下走)

  • 其他线程调用了该线程的 interrupt() 方法,该线程会 InterruptedException 异常返回

如果调用 wait() 方法的线程没有事先获取该对象的监视器锁,调用线程会抛出IllegalMonitorStateException 异常。当线程调用wait() 之后,就已经释放了该对象的监视器锁

那么,一个线程如何才能获取一个共享变量的监视器锁?

  1. 执行synchronized 同步代码块,使用该共享变量作为参数。

    synchronized(共享变量) {
        // TODO
    }
    
  2. 调用该共享变量的同步方法(synchronized 修饰)

    synchronized void sum(int a, int b) {
        // TODO
    }
    

3.2 notify() / notifyAll()

一个线程调用共享对象的 notify() 方法后,会唤醒一个在该共享变量上调用 wait(...) 系列方法后被挂起的线程。

值得注意的是:

  • 一个共享变量上可能会有多个线程在等待,notify() 具体唤醒哪个等待的线程是随机的
  • 被唤醒的线程不能马上从wait方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回,也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行

notifyAll() 方法则会唤醒所有在该共享变量上由于调用wait系列方法而被挂起的线程。

3.3 实例

比较经典的就是生产者和消费者的例子

public class NotifyWaitDemo {

    public static final int MAX_SIZE = 1024;
    // 共享变量
    public static Queue queue = new Queue();

    public static void main(String[] args) {
        // 生产者
        Thread producer = new Thread(() -> {
            synchronized (queue) {
                while (true) {
                    // 挂起当前线程(生产者线程)
                    // 并且,释放通过queue的监视器锁,让消费者对象获取到锁,执行消费逻辑
                    if (queue.size() == MAX_SIZE) {
                        try {
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 空闲则生成元素,并且通知消费线程
                    queue.add();
                    queue.notifyAll();
                }
            }
        });
        // 消费者
        Thread consumer = new Thread(() -> {
            synchronized (queue) {
                while (true) {
                    // 挂起当前线程(消费者线程)
                    // 并且,释放通过queue的监视器锁,让生产者对象获取到锁,执行生产逻辑
                    if (queue.size() == 0) {
                        try {
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 空闲则消费元素,并且通知生产线程
                    queue.take();
                    queue.notifyAll();
                }
            }
        });
        producer.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        consumer.start();
    }

    static class Queue {

        private int size = 0;

        public int size() {
            return this.size;
        }

        public void add() {
            // TODO
            size++;
            System.out.println("执行add 操作,current size: " +  size);
        }

        public void take() {
            // TODO
            size--;
            System.out.println("执行take 操作,current size: " +  size);
        }
    }
}

3.4 wait()/notify()/notifyAll() 为什么定义在 Object 类中?

由于Thread类继承了Object类,所以Thread也可以调用者三个方法,等待和唤醒必须是同一个锁。而锁可以是任意对象,所以可以被任意对象调用的方法是定义在object类中。

四、join() - 等待线程执行终止

适用场景:需要等待某几件事情完成后才能继续往下执行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理。

public static void main(String[] args){
    ...
    thread1.join();
    thread2.join();
    System.out.println("all child thread over!");
}

主线程首先会在调用thread1.join() 后被阻塞,等待thread1执行完毕后,调用thread2.join(),等待thread2 执行完毕(有可能),以此类推,最终会等所有子线程都结束后main函数才会返回。如果其他线程调用了被阻塞线程的 interrupt() 方法,被阻塞线程会抛出 InterruptedException 异常而返回。

4.1 实例

给出一个实例帮助理解。

public class JoinExample {

    private static final int TIMES = 100;

    private class JoinThread extends Thread {

        JoinThread(String name){
           super(name);
        }

        @Override
        public void run() {
            for (int i = 0; i < TIMES; i++) {
                System.out.println(getName() + " " + i);
            }
        }
    }

    public static void main(String[] args) {
        JoinExample example = new JoinExample();
        example.test();
    }

    private void test() {
       for (int i = 0; i < TIMES; i++) {
           if (i == 20) {
               Thread jt1 = new JoinThread("子线程1");
               Thread jt2 = new JoinThread("子线程2");
               jt1.start();
               jt2.start();
               // main 线程调用了jt线程的join()方法
               // main 线程必须等到 jt 执行完之后才会向下执行
               try {
                   jt1.join();
                   jt2.join();
                   // join(long mills) - 等待时间内 被join的线程还没执行,不再等待
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
           System.out.println(Thread.currentThread().getName() + "  " + i);
        }
    }
}

五、线程睡眠

sleep()会使线程暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度但不会释放锁

指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与CPU的调度,获取到CPU资源后就可以继续运行了。

如果在睡眠期间其他线程调用了该线程的 interrupt() 方法中断了该线程,则该线程会在调用sleep方法的地方抛出 InterruptedException 异常而返回。

/**
 * 帮助理解 sleep 不会让出监视器锁资源
 *
 * 在线程A睡眠的这10s内obj的监视器锁还是线程A自己持有
 * 线程B会一直阻塞直到线程A醒来后退出synchronize代码块 释放锁
 *
 * @author Richard_yyf
 * @version 1.0 2020/3/12
 */
public class ThreadSleepDemo {

    
    private static final Object obj = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            // 获取obj monitor 锁
            synchronized (obj) {
                try {
                    System.out.println("child thread A is in sleep");
                    Thread.sleep(10000);
                    System.out.println("child thread A is awake");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread threadB = new Thread(() -> {
            // 获取obj monitor 锁
            synchronized (obj) {
                try {
                    System.out.println("child thread B is in sleep");
                    Thread.sleep(10000);
                    System.out.println("child thread B is awake");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

六、让出CPU执行权 - yield()

线程调用yield 方法时,实际上是暗示线程调度器当前线程请求让出自己的CPU使用(告诉线程调度器可以进行下一轮的线程调度),但线程调度器可以无条件忽略这个暗示

我们知道操作系统是为每个线程分配一个时间片来占有CPU的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了Thread类的静态方法 yield 时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。

一般很少使用这个方法,在调试或者测试时这个方法或许可以帮助复现由于并发竞争条件导致的问题,其在设计并发控制时或许会有用途,在java.util.concurrent.locks包里面的锁时会看到该方法的使用

sleep与yield方法的区别在于:当线程调用sleep方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用yield 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。

七、线程中断

很多人看到 interrupt() 方法,认为“中断”线程不就是让线程停止嘛。实际上, interrupt() 方法实现的根本就不是这个效果, interrupt()方法更像是发出一个信号,这个信号会改变线程的一个标识位属性(中断标识),对于这个信号如何进行响应则是无法确定的(可以有不同的处理逻辑)。很多时候调用 interrupt() 方法非但不是为了停止线程,反而是为了让线程继续运行下去

官方一点的表述:

Java中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理

7.1 void interrupt()

设置线程的中断标志为true并立即返回,但线程实际上并没有被中断而会继续向下执行;如果线程因为调用了wait系列函数、join方法或者sleep方法而被阻塞挂起,其他线程调用该线程的interrupt()方法会使该线程抛出InterruptedException异常而返回。

7.2 boolean isInterrupted()

检测线程是否被中断,是则返回true,否则返回false。

	public boolean isInterrupted() {
        // 传递 false 说明不清除中断标志
        return isInterrupted(false);
    }

7.3 boolean interrupted()

检测当前线程是否被中断,返回值同上 isInterrupted() ,不同的是,如果发现当前线程被中断,会清除中断标志;该方法是static方法,内部是获取当前调用线程的中断标志而不是调用interrupted()方法的实例对象的中断标志

    public static boolean interrupted() {
        // static 方法
        // true - 清除终端标志
        // currentThread
        return currentThread().isInterrupted(true);
    }

八、线程上下文切换

我们都知道,在多线程编程中,线程个数一般都大于CPU 个数,而每个CPU同一时刻只能被一个线程使用。

为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的策略,也就是每个线程分配一个时间片,线程在时间片内占用CPU执行任务。当前线程使用完时间片后,就会处于就绪状态并且让出CPU让其他线程占用,这就是线程的上下文切换

九、线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。

就如上图,线程A持有资源2,同时想申请资源1,线程B持有资源1,同时想申请资源2,两个线程相互等待就形成了死锁状态。

死锁的产生有四个条件:

  • 互斥条件:指线程对已经获取到的资源进行排他性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
  • 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
  • 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
  • 环路等待条件:指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合{T0, T1, T2, …, Tn}中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,……Tn正在等待已被T0占用的资源。

十、守护线程与用户线程

Java 线程分为两类,

  • daemon 线程(即守护线程)
  • user 线程 (用户线程)

在JVM 启动时会调用 main 函数,main 函数所在的线程就是一个用户线程,同时在JVM 内部也启动了很多守护线程,比如 GC 线程。

守护线程和用户线程的区别在于,守护线程不会影响JVM 的退出,当最后一个用户线程结束时,JVM 会正常退出。

所以,如果你希望在主线程结束后JVM进程马上结束,那么在创建线程时可以将其设置为守护线程,如果你希望在主线程结束后子线程继续工作,等子线程结束后再让JVM进程结束,那么就将子线程设置为用户线程

举例:比如在Tomcat 的NIO 实现NioEndpoint 类中,会开启一组接受线程来接受用户的连接请求,以及一组处理线程负责具体处理用户请求。

	/**
     * Start the NIO endpoint, creating acceptor, poller threads.
     */
    @Override
    public void startInternal() throws Exception {

        if (!running) {
			// ... 省略

            // Start poller threads 处理线程
            pollers = new Poller[getPollerThreadCount()];
            for (int i=0; i<pollers.length; i++) {
                pollers[i] = new Poller();
                Thread pollerThread = new Thread(pollers[i], getName() + "-ClientPoller-"+i);
                pollerThread.setPriority(threadPriority);
                pollerThread.setDaemon(true); // 
                pollerThread.start();
            }
			// 启动接收线程
            startAcceptorThreads();
        }
    }

    protected final void startAcceptorThreads() {
        int count = getAcceptorThreadCount();
        acceptors = new Acceptor[count];

        for (int i = 0; i < count; i++) {
            acceptors[i] = createAcceptor();
            String threadName = getName() + "-Acceptor-" + i;
            acceptors[i].setThreadName(threadName);
            Thread t = new Thread(acceptors[i], threadName);
            t.setPriority(getAcceptorThreadPriority());
            t.setDaemon(getDaemon()); // 默认值是true 
            t.start();
        }
    }

在如上代码中,在默认情况下,接受线程和处理线程都是守护线程,这意味着当tomcat收到shutdown命令后并且没有其他用户线程存在的情况下tomcat进程会马上消亡,而不会等待处理线程处理完当前的请求。

小结

本篇讲了有关Java 线程的一些基础知识。下一篇,我计划是写一下 Java 线程的生命周期、线程的各个状态和以及各个状态的流转。因为关于这部分我感觉国内网站上大部分的文章都没有讲清楚,我会尝试用图片、文字和具体的代码结合的方式写一篇。有兴趣的小伙伴可以关注一下。

如果本文有帮助到你,希望能点个赞,这是对我的最大动力🤝🤝🤗🤗。

参考

  • 《Java 并发编程之美》