互联网科技Java 杂谈程序员

一文解决你不理解的并发与多线程问题!(上)

2019-03-17  本文已影响4人  Java_老男孩

前排温馨提示:由于文章写完后篇幅较长,所以我选择了上下文的形式发布

创建并启动线程

熟悉Java的人都能很容易地写出如下代码:

public static class MyThread extends Thread {
    <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override
    public void run() {
        System.out.println("MyThread is running...");
    }
}
public static void main(String[] args) {
    Thread t = new MyThread();
    t.start();
}</a>

线程的生命周期

这是一个面试常问的基础问题,你应该肯定的回答线程只有五种状态,分别是:新建状态、就绪状态、执行状态、阻塞状态、终止状态。

就绪状态和执行状态

由于Scheduler(调度器)的时间片分配算法,每个Running的线程会执行多长时间是未知的,因此线程能够在Runnable和Running之间来回转换。阻塞状态的线程必须先进入就绪状态才能进入执行状态

执行状态和阻塞状态

Running线程在主动调用Thread.sleep()、obj.wait()、thread.join()时会进入TIMED-WAITING或WAITING状态并主动让出CPU执行权。如果是TIMED-WAITING,那么在经过一定的时间之后会主动返回并进入Runnable状态等待时间片的分配。

thread.join()的底层就是当前线程不断轮询thread是否存活,如果存活就不断地wait(0)。

Running线程在执行过程中如果遇到了临界区(synchronized修饰的方法或代码块)并且需要获取的锁正在被其他线程占用,那么他会主动将自己挂起并进入BLOCKED状态。

阻塞状态和就绪状态

如果持有锁的线程退出临界区,那么在该锁上等待的线程都会被唤醒并进入就绪状态,但只有抢到锁的线程会进入执行状态,其他没有抢到锁的线程仍将进入阻塞状态。

如果某个线程调用了obj的notify/notifyAll方法,那么在该线程退出临界区时(调用wait/notify必须先通过synchronized获取对象的锁),被唤醒的等待在obj.wait上的线程才会从阻塞状态进入就绪状态获取obj的monitor,并且只有抢到monitor的线程才会从obj.wait返回,而没有抢到的线程仍旧会阻塞在obj.wait上

终止状态

在执行状态下的线程执行完run方法或阻塞状态下的线程被interrupt时会进入终止状态,随后会被销毁。

start源码剖析

public synchronized void start() {
    if (threadStatus != 0)
        throw new IllegalThreadStateException();
    group.add(this);
    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {}
    }
}
private native void start0();

start方法主要做了三件事:

  1. 将当前线程对象加入其所属的线程组(线程组在后续将会介绍)
  2. 调用start0,这是一个native方法,在往期文章《Java线程是如何实现的?》一文中谈到线程的调度将交给LWP,这里的启动新建线程同样属于此范畴。因此我们能够猜到此JNI(Java Native Interface)调用将会新建一个线程(LWP)并执行该线程对象的run方法
  3. 将该线程对象的started状态置为true表示已被启动过。正如初学线程时老师所讲的,线程的start只能被调用一次,重复调用会报错就是通过这个变量实现的。

为什么要引入Runnable

单一职责原则

我们将通过Thread来模拟这样一个场景:银行多窗口叫号。从而思考已经有Thread了为什么还要引入Runnable

首先我们需要一个窗口线程模拟叫号(窗口叫号,相应号码的顾客到对应窗口办理业务)的过程:



public class TicketWindow extends Thread {
    public static final Random RANDOM = new Random(System.currentTimeMillis());
    private static final int MAX = 20;
    private int counter;
    private String windowName;
    public TicketWindow(String windowName) {
        super(windowName);
        counter = 0;
        this.windowName = windowName;
    }
    <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override
    public void run() {
        System.out.println(windowName + " start working...");
        while (counter < MAX){
            System.out.println(windowName + ": It's the turn to number " + counter++);
            //simulate handle the business
            try {
                Thread.sleep(RANDOM.nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}</a>

然后编写一个叫号客户端模拟四个窗口同时叫号:

public class WindowThreadClient {
    public static void main(String[] args) {
        Stream.of("Window-1","Window-2","Window-3","Window-4").forEach(
            windowName -> new TicketWindow(windowName).start()
        );
    }
}

你会发现同一个号码被叫了四次,显然这不是我们想要的。正常情况下应该是四个窗口共享一个叫号系统,窗口只负责办理业务而叫号则应该交给叫号系统,这是典型的OOP中的单一职责原则。

我们将线程和要执行的任务耦合在了一起,因此出现了如上所述的尴尬情况。线程的职责就是执行任务,它有它自己的运行时状态,我们不应该将要执行的任务的相关状态(如本例中的counter、windowName)将线程耦合在一起,而应该将业务逻辑单独抽取出来作为一个逻辑执行单元,当需要执行时提交给线程即可。于是就有了Runnable接口:

public interface Runnable {
    public abstract void run();
}

因此我们可以将之前的多窗口叫号改造一下:

public class TicketWindowRunnable implements Runnable {
    public static final Random RANDOM = new Random(System.currentTimeMillis());
    private static final int MAX = 20;
    private int counter = 0;
    <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " start working...");
        while (counter < MAX){
            System.out.println(Thread.currentThread().getName()+ ": It's the turn to number " + counter++);
            //simulate handle the business
            try {
                Thread.sleep(RANDOM.nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}</a>

测试类:

public class WindowThreadClient {
    public static void main(String[] args) {
        TicketWindowRunnable ticketWindow = new TicketWindowRunnable();
        Stream.of("Window-1", "Window-2", "Window-3", "Window-4").forEach(
                windowName -> new Thread(ticketWindow, windowName).start()
        );
    }
}

如此你会发现没有重复的叫号了。但是这个程序并不是线程安全的,因为有多个线程同时更改windowRunnable中的counter变量,由于本节主要阐述Runnable的作用,因此暂时不对此展开讨论。

策略模式和函数式编程

将Thread中的run通过接口的方式暴露出来还有一个好处就是对策略模式和函数式编程友好。

首先简单介绍一下策略模式,假设我们现在需要计算一个员工的个人所得税,于是我们写了如下工具类,传入基本工资和奖金即可调用calculate得出应纳税额:

public class TaxCalculator {
    private double salary;
    private double bonus;
    public TaxCalculator(double base, double bonus) {
        this.salary = base;
        this.bonus = bonus;
    }
    public double calculate() {
        return salary * 0.03 + bonus * 0.1;
    }
}

这样写有什么问题?我们将应纳税额的计算写死了:salary * 0.03 + bonus * 0.1,而税率并非一层不变的,客户提出需求变动也是常有的事!难道每次需求变更我们都要手动更改这部分代码吗?

这时策略模式来帮忙:当我们的需求的输入是不变的,但输出需要根据不同的策略做出相应的调整时,我们可以将这部分的逻辑抽取成一个接口:

public interface TaxCalculateStrategy {
    public double calculate(double salary, double bonus);
}

具体策略实现:

public class SimpleTaxCalculateStrategy implements TaxCalculateStrategy {
    <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override
    public double calculate(double salary, double bonus) {
        return salary * 0.03 + bonus * 0.1;
    }
}</a>

而业务代码仅调用接口:

public class TaxCalculator {
    private double salary;
    private double bonus;
    private TaxCalculateStrategy taxCalculateStrategy;
    public TaxCalculator(double base, double bonus, TaxCalculateStrategy taxCalculateStrategy) {
        this.salary = base;
        this.bonus = bonus;
        this.taxCalculateStrategy = taxCalculateStrategy;
    }
    public double calculate() {
        return taxCalculateStrategy.calculate(salary, bonus);
    }
}

将Thread中的逻辑执行单元run抽取成一个接口Runnable有着异曲同工之妙。因为实际业务中,需要提交给线程执行的任务我们是无法预料的,抽取成一个接口之后就给我们的应用程序带来了很大的灵活性。

另外在JDK1.8中引入了函数式编程和lambda表达式,使用策略模式对这个特性也是很友好的。还是借助上面这个例子,如果计算规则变成了(salary + bonus) * 1.5,可能我们需要新增一个策略类:

public class AnotherTaxCalculatorStrategy implements TaxCalculateStrategy {
    <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override
    public double calculate(double salary, double bonus) {
        return (salary + bonus) * 1.5;
    }
}</a>

在JDK增加内部类语法糖之后,可以使用匿名内部类省去创建新类的开销:

public class TaxCalculateTest {
    public static void main(String[] args) {
        TaxCalculator taxCalaculator = new TaxCalculator(5000,1500, new TaxCalculateStrategy(){
            <a href="/profile/992988" data-card-uid="992988" class="js-nc-card" target="_blank" style="color: #25bb9b">@Override
            public double calculate(double salary, double bonus) {
                return (salary + bonus) * 1.5;
            }
        });
    }
}</a>

但是在JDK新增函数式编程后,可以更加简洁明了:

public class TaxCalculateTest {
    public static void main(String[] args) {
        TaxCalculator taxCalaculator = new TaxCalculator(5000, 1500, (salary, bonus) -> (salary + bonus) * 1.5);
    }
}

这对只有一个抽象方法run的Runnable接口来说是同样适用的。

构造Thread对象,你也许不知道的几件事

查看Thread的构造方法,追溯到init方法(略有删减):

Thread parent = currentThread();
if (g == null) {
    if (g == null) {
        g = parent.getThreadGroup();
    }
}
this.group = g;
this.daemon = parent.isDaemon();
this.priority = parent.getPriority();
this.target = target;
setPriority(priority);
this.stackSize = stackSize;
tid = nextThreadID();

通过nextThreadID会发现是一个static synchronized方法,原子地取得线程序列号threadSeqNumber自增后的值:

public static void main(String[] args) {
    new Thread(() -> {
        System.out.println(Thread.currentThread().getId()); //11
    }).start();
}

为什么main中创建的第一个线程的ID是11(意味着他是JVM启动后创建的第11个线程)呢?这因为在JVM在执行main时会启动JVM进程的第一个线程(叫做main线程),并且会启动一些守护线程,比如GC线程。

多线程与JVM内存结构

JVM内存结构

这里要注意的是每个线程都有一个私有的虚拟机栈。所有线程的栈都存放在JVM运行时数据区域的虚拟机栈区域中。

栈帧内存结构

stackSize参数

Thread提供了一个可以设置stackSize的重载构造方法:

public Thread(ThreadGroup group,
              Runnable target,
              String name,
              long stackSize)

官方文档对该参数的描述如下:

The stack size is the approximate number of bytes of address space that the virtual machine is to allocate for this thread's stack. The effect of the stackSize parameter, if any, is highly platform dependent.

你能通过指定stackSize参数近似地指定虚拟机栈的内存大小(注意:是内存大小即字节数而不是栈中所能容纳的最大栈帧数目,而且这个大小指的是该线程的栈大小而并非是整个虚拟机栈区的大小)。且该参数具有高度的平台依赖性,也就是说在各个操作系统上,同样的参数表现出来的效果有所不同。

On some platforms, specifying a higher value for thestackSizeparameter may allow a thread to achieve greater recursion depth before throwing a StackOverflowError. Similarly, specifying a lower value may allow a greater number of threads to exist concurrently without throwing an OutOfMemoryError (or other internal error). The details of the relationship between the value of thestackSizeparameter and the maximum recursion depth and concurrency level are platform-dependent. On some platforms, the value of the stackSize parameter may have no effect whatsoever.

在一些平台上,为stackSize指定一个较大的值,能够允许线程在抛出栈溢出异常前达到较大的递归深度(因为方法栈帧的大小在编译期可知,以局部变量表为例,基本类型变量中只有longdouble占8个字节,其余的作4个字节处理,引用类型根据虚拟机是32位还是64位而占4个字节或8个字节。如此的话栈越大,栈所能容纳的最大栈帧数目也即递归深度也就越大)。类似的,指定一个较小的stackSize能够让更多的线程共存而避免OOM异常(有的读者可能会异或,栈较小怎么还不容易抛出OOM异常了呢?不是应该栈较小,内存更不够用,更容易OOM吗?其实单线程环境下,只可能发生栈溢出而不会发生OOM,因为每个方法对应的栈帧大小在编译器就可知了,线程启动时会从虚拟机栈区划分一块内存作为栈的大小,因此无论是压入的栈帧太多还是将要压入的栈帧太大都只会导致栈无法继续容纳栈帧而抛出栈溢出。那么什么时候回抛出OOM呢。对于虚拟机栈区来说,如果没有足够的内存划分出来作为新建线程的栈内存时,就会抛出OOM了。这就不难理解了,有限的进程内存除去堆内存、方法区、JVM自身所需内存之后剩下的虚拟机栈是有限的,分配给每个栈的越少,能够并存的线程自然就越多了)。最后,在一些平台上,无论将stackSize设置为多大都可能不会起到任何作用。

The virtual machine is free to treat thestackSizeparameter as a suggestion. If the specified value is unreasonably low for the platform, the virtual machine may instead use some platform-specific minimum value; if the specified value is unreasonably high, the virtual machine may instead use some platform-specific maximum. Likewise, the virtual machine is free to round the specified value up or down as it sees fit (or to ignore it completely).

虚拟机会将stackSize视为一种建议,在栈大小的设置上仍有一定的话语权。如果给定的值太小,虚拟机会将栈大小设置为平台对应的最小栈大小;相应的如果给定的值太大,则会设置成平台对应的最大栈大小。又或者,虚拟机能够按照给定的值向上或向下取舍以设置一个合适的栈大小(甚至虚拟机会忽略它)。

Due to the platform-dependent nature of the behavior of this constructor, extreme care should be exercised in its use. The thread stack size necessary to perform a given computation will likely vary from one JRE implementation to another. In light of this variation, careful tuning of the stack size parameter may be required, and the tuning may need to be repeated for each JRE implementation on which an application is to run.

由于此构造函数的平台依赖特性,在使用时需要格外小心。线程栈的实际大小的计算规则会因为JVM的不同实现而有不同的表现。鉴于这种变化,可能需要仔细调整堆栈大小参数,并且对于应用程序使用的不同的JVM实现需要有不同的调整。

Implementation note: Java platform implementers are encouraged to document their implementation's behavior with respect to thestackSizeparameter.

上一篇下一篇

猜你喜欢

热点阅读