J2SE复习内容 - 多线程基础

在这之前应该有相应的操作系统基础知识,包括线程,进程,通信等。

系列目录:
J2SE复习内容 - 多线程基础
J2SE复习内容 - 多线程进阶

1. 线程基础

Java的线程相关概念定义在java.lang包下的Thread中,通过new Thread()来创建一个线程,通过start()方法启动一个线程。

同时可以使用Runnable或者Callable接口的实现来开启一个新线程,但归根结底都是调用其中的run(或者call)方法,来实现自己的逻辑。

通过上面的简单总结,会得出一个小问题,如下:

Q1: 为什么要调用start()方法,而不是直接调用run()方法?
回答这个问题,我们不如先找个例子测试一下。

先写一个Runnable

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() +"@"+i);
        }
    }
}

在写main方法

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable(),"MyThread");
        t.start();

        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() +":"+i);
        }
    }
}

按照并行的原则来查看结果:


确实是两个线程交替执行的。
但是我们如果调用MyRunnable的run方法呢?


可以看到结果输出的是有顺序的,但是线程都是main线程。
同理我们如果这样写:

Thread t = new Thread(new MyRunnable(),"MyThread");
t.run();

也是这样的结果,那为啥会出现这种结果呢?画个简单的图就明白了。

正常start:



正常启动主线程和子线程会并行执行(宏观方面,忽略调度),由myThread来执行run方法。

image.png

而调用run,就是简单的调用,按照箭头的走向,先执行main,然后调用run,run执行完了回到main,最后main执行完,其中没有线程的概念,而只是简单的类中调用。

具体start在背后做了什么,我们暂时不关注,但是明白简单的调用run只会在本线程中建立了调用栈,而不是创建新线程。

这里扩展一下,看一下Thread的join()方法,


image.png

,官方文档上面写的很清楚,Waits for this thread to die,等我死了,你再执行。

根据上图发现join的执行流程和调用run的类似,但是join确实是新开了一个子线程,大致的流程就是,主线程开始运行,新建了子线程开始运行,然后子线程调用join,主线程会等待子线程完全执行完毕,然后自己再执行。

2. 调度基础

类似于进程,线程的调度也是分为几个状态。
如图所示,我们调用start()方法后进入了就绪状态,而不是运行状态,这一点要明白。

在调度里面有两个方法需要做区分,sleep()和yield(), 两者说起来也不是很难区分,下面简单的理解一下二者的区别。

Q2: yield() 和 sleep()有什么区别?
首先两者都可以用来调度,或者说用来让出CPU,但二者的区别很明显,我们看一下源码。

yield
sleep

根据图片总结不同点
1.yield无法指定时间,而sleep可以
2.yield无异常抛出,而sleep可以抛出异常(在被别人打断睡眠的时候)。

其他的不同点:

  1. 参考上面的调度图,如果我们调用的是sleep,线程应该是从运行态进入了阻塞状态(睡眠式放弃),到时间之后继续回到就绪状态竞争CPU,而如果我们调用yield,线程应该从运行态进入就绪态(主动放弃),立刻又重新竞争CPU。

所以这样就有一个细节需要注意了,yield调用后,让出CPU的线程很有可能又获得了CPU,但这样不是缺憾,因为yield方法本身就不常用,上面的注释可以看出,该方法主要是用于测试的。

  1. sleep()方法给其它线程运行时,不考虑线程的优先级;而yield()方法只会给相同优先级或更高优先级的线程运行的机会。

同时还有一点要提,参考我们上面sleep源码中的注释

The thread does not lose ownership of any monitors.

线程不丢失监视器所属权,说白了就是不会释放同步锁,也就是sleep方法调用的时候,加锁的线程还是会加锁,所以某些情境下,sleep调用并不会让出临界区,而是占着坑不XX

接下来考虑一个常见的问题,如何停止一个线程?
Q3: 怎样安全的停止一个线程
这个问题相信很多人也已经知道答案了,简单的方法就是采用结束标志位,在讨论这个方法之前,有几种方法要探究一下。
第一种,interrupt,代码如下:

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        while (true){
            try {
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName()+" : "+System.currentTimeMillis());
            } catch (InterruptedException e) {
               return;
            }
        }
    }
}
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable(),"MyThread");
        t.start();

        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

某些方法,如sleep会抛出InterruptedException ,所以借助Thread的interrupt方法可以致使其停止运行。
但是这个操作是危险且不合适的,借助异常机制来停止运行本身就不是很好的方法。同时要是根据isInterrupted方法来判断是否停止更危险(由于抛出异常之后的复位,这些情况下根本停不下来)

查阅API文档发现Thread类中还有一个方法。


image.png

stop方法,看起来很完美的样子,但是为什么被标注过时了呢?原因就是这个方法很危险。具体我们来看看官方怎么说。


下面是个人翻译 + 理解

stop()天生就不是一个安全的方法, 用这个方法会导致它自身所有的同步锁被释放,那么在stop调用之前所有的受同步锁保护的变量(临界区)将会产生不确定的风险,由此带来的风险是不可控的。

也就是说,stop方法会导致同步失效,不是一个安全的好办法,除此之外,更严重的是,调用了stop的线程会立马死掉,而不会管你文件是否关闭,资源是否释放等等,这样体验很不好。

同时下面也给出了一个方案,就是我们上面提到的标志位方法。
代码如下:

public class MyRunnable implements Runnable {

    private boolean flag = true;

    @Override
    public void run() {
        while (flag){
            try {
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName()+" : "+System.currentTimeMillis());
            } catch (InterruptedException e) {
               return;
            }
        }
    }

    public void shutdown(){
        flag = false;
    }
}

public class Main {
    public static void main(String[] args) {
        MyRunnable m = new MyRunnable();
        Thread t = new Thread(m,"MyThread");
        t.start();


        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        m.shutdown();

    }
}

3. 线程同步

这里的同步可能和操作系统里的同步概念不太一样,操作系统中同步是为了规定执行顺序,互斥是为了保护临界区,而Sychronized作为同步关键字,是完成了一个互斥操作,又叫做互斥 锁,但在此不深究,只知道这里面的功能对应起来就行。

而我们常见的锁机制会有这样一个分类,比如说乐观锁悲观锁,其中CAS就是属于乐观锁的一种,而SychronizedReentrantLock属于悲观锁。
再比如我们说Sychronized是一种隐式锁,系统自动完成加锁解锁,ReentrantLock属于显式锁,需要手动完成加锁解锁。

再回到应用层面,Sychronized又分为语句块写法和方法体写法,等等。
但不管是乐观悲观与否,写法是如何,他们所达到的目的就是临界区互斥。

在使用Sychronized关键字的时候,也可以产生死锁,而死锁就是一种线程之间互相等待的僵局,如下面代码,m1和m2方法执行过程中,分别锁定了object1和object2,但是随后又要申请对方的锁,而此时对方又无法执行完代码释放锁,所以就处于一种尴尬的状态。

public class DeadLock {

    Object object1 = new Object();
    Object object2 = new Object();
    
    public void m1() throws InterruptedException {
        synchronized (object1){
            Thread.sleep(1000);
            synchronized (object2){
                //do sth
            }
        }
    }
    
    public void m2() throws InterruptedException {
        synchronized (object2){
            Thread.sleep(1000);
            synchronized (object1){
                //do sth  
            }
        }
    }
}

4. 生产者消费者模型

生产者消费者模型如果在OS课程中学到过就不难理解了,因为下面的实现跟我们在OS中学习到的PV操作是一样的。

package Thread;

import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

/**
 * 生产者消费者模型
 * */
public class PCModel {

    private final int MAX_LEN = 20;
    private List list = new LinkedList<>();

    class Producter implements Runnable{
        @Override
        public void run() {
            produce();
        }

        //生产操作
        private  void produce() {
            while (true){
                synchronized (list) {
                    //等待
                    while(list.size() == MAX_LEN){
                        try {
                            list.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    //通知唤醒
                    list.notifyAll();
                    //添加元素
                    list.add((int) Math.random() * 100);
                }
            }
        }
    }

    class Consumer implements Runnable{

        @Override
        public void run() {
           consume();
        }

        private void consume() {
            while(true){
                synchronized (list) {
                    //等待
                    while(list.size() == 0){
                        try {
                            list.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    //通知唤醒
                    list.notifyAll();
                    //取出元素
                    list.remove(list.size() - 1);
                }
            }
        }
    }
}

面试过程中,很有可能被要求手撕代码,除了上面这种最简单的,还可以使用BlockingQueue来实现,而后者需要明白,内部也是相应的阻塞原理,只需简单的调用他所提供的API即可实现。

对于上面所给出的代码有几点需要注意。
而这几点也很有意思,一起来探讨一下。

Q4 : 为什么这段代码要用While而不用if ?

 while(list.size() == MAX_LEN){
      try {
            list.wait();
      } catch (InterruptedException e) {
             e.printStackTrace();
     }
 }

按照道理,判断一下队列已满,就进入wait()方法即可,但为什么要用while呢?理由如下:当一个线程执行了list.wait() 之后被Interrupt了,抛出了异常,如果用if执行完就会向下进行,这样的结果就不可预知,明明需要wait的情况又往下去生产了(或者消费了),就会产生系列问题,而我们用while的话,可以进行多次判断,直到某一次真正进入等待状态才可以。

Q5:为什么用notifyAll() 而不是notify() 方法?
想象一下有两个生产者的场景,可不可以某些情况下生产者notify的还是一个生产者(看似不可能),但是为了保险期间,我们使用notifyAll来随机唤醒一个线程,这样就尽可能的避免出现问题,当然很多写法仍然是notify,对于这个问题,简单思考一下即可。

5. wait和sleep

在上面我们比较了yeild和sleep的区别,下面又有一个新的问题,
Q6: wait和sleep有什么区别?

  1. 首先我们要找爹,wait是Object的孩子,sleep是Thread的孩子,也就是说wait
    每个类都有,这也就是上面为什么可以调用list的wait方法的原因。

接下来看一下DOC,


就是在notify/notifyAll调用之前导致thread进入wait状态,且wait必须要持有一个对象监视器(同理notify两个方法也需要),

  1. 而当线程进入wait状态的时候,监视器(暂时理解为锁)将会被释放,但sleep不会,上面提到过(sleep是占着啥啥啥不啥啥啥)。

补充一下 notify不会释放锁,只是会发送通知进入工作状态,需要执行完后面的代码才会释放锁。

  1. 再根据上面所说的,wait会强制要求添加对象监视器,而sleep却没有要强制添加,如果wait没有对象监视器,将会抛出IllegalMonitorStateException异常

6. 总结

本文整理了一些多线程的入门知识,并且稍微深入一些问题,对一些方法的作用和一些经典例子做了整理。

文章中整理了6个问题,虽然知识点跳跃,但是联系性还是很强的,相互比较学习可以加深对线程机制的了解。

你可能感兴趣的:(J2SE复习内容 - 多线程基础)