Java学习笔记21:并发(1)

Java学习笔记21:并发(1)

5c9c3b3b392ac581.jpg

图源:PHP中文网

并发是《Thinking in Java》的最后一个章节(其实后边还有一个图形界面,但Java的图形界面组件早就被废弃了)。也是一个相当庞大和难以学习的章节,相关的概念性描述非常多,这里我没办法照搬原文(篇幅不允许),所以更多的是直接展示如何用代码实现,完整的概念性描述可以直接参考原书。

基本概念

Java将并发抽象成线程和任务,前者对应Thread类,后者对应RunnableCallable接口。

Thread

可以通过从Thread类继承并创建实例的方式来编写简单的并发程序:

package ch21.thread;

import util.Fmt;

class SimpleThread extends Thread {
    private int counter = 5;
    private final int id;

    public SimpleThread(int id) {
        super();
        this.id = id;
    }

    @Override
    public void run() {
        do {
            System.out.println(this);
            counter--;
        } while (counter > 0);
    }

    @Override
    public String toString() {
        return Fmt.sprintf("#%d,counter=%d", id, counter);
    }

}

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            Thread thread = new SimpleThread(i);
            thread.start();
        }
    }
}
// #1,counter=5
// #0,counter=5
// #2,counter=5
// #0,counter=4
// #1,counter=4
// #2,counter=4
// #0,counter=3
// #1,counter=3
// #2,counter=3
// #0,counter=2
// #2,counter=2
// #2,counter=1
// #1,counter=2
// #0,counter=1
// #1,counter=1

Thread.start方法将在主线程(main)中启动一个子线程,该子线程执行的代码由Thread.run方法定义。

这个代码本身很简单,三个子线程从5开始递减,直到1为止。需要注意的是:

  • 线程调度本身是由JVM的线程调度器决定的,而非开发者。因此我们会看到输出中有同一个线程接连输出的情况存在。也就是说什么时候会发生线程调度,这取决于JVM的线程调度器。但是我们可以通过一些方式来“建议”线程调度器执行线程调度。
  • 如果在Go或Python中编写类似于上边的代码,你就会发现程序不会发生任何输出。这是因为在Go和Python中,程序的生命周期由主线程main决定,类似于上边的代码,会导致主线程在启动子线程后直接执行完毕退出,进而导致整个程序退出,所有子线程也会被销毁。但是Java不会,Java程序会等待所有的“非服务线程”(包括主线程)结束后退出,而线程默认都是“非服务线程”。

如果希望这个示例的输出“更均匀”,也就是每个线程在打印后切换到其它线程,不要连续输出,可以通过添加Thread.yield方法对线程调度器建议进行线程调度:

	...
    @Override
    public void run() {
        do {
            System.out.println(this);
            counter--;
            Thread.yield();
        } while (counter > 0);
    }
    ...

当然这里也只是理论上的,实际上输出结果还和你的计算机硬件相关。这里真正负责打印的是3个子线程,如果是单核2线程的处理器,那么输出可能不会存在连续性,因为实际上最多只有两个线程同时运行。但如果是3个线程以上的处理器,这3个子线程完全可能是同时运行的,所以依然可能会出现某个子线程连续输出的情况。

Runnable

虽然上面的例子可以编写多线程程序,但是在Java的多线程编程中,实际上是将“线程”和“任务”这两个概念分离的,“线程”更多的是指可以并发运行的一个东西,而具体执行的代码由其“绑定”的“任务”确定。

Runnable接口可以表示一个简单的无返回任务:

package ch21.runnable1;

import util.Fmt;

class SimpleTask implements Runnable {
    private final int id;
    private int counter = 5;

    public SimpleTask(int id) {
        this.id = id;
    }

    @Override
    public void run() {
        do {
            System.out.println(this);
            counter--;
            Thread.yield();
        } while (counter > 0);
    }

    @Override
    public String toString() {
        return Fmt.sprintf("#%d,counter=%d", id, counter);
    }

}

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            Thread thread = new Thread(new SimpleTask(i));
            thread.start();
        }
    }
}
// #2,counter=5
// #0,counter=5
// #1,counter=5
// #2,counter=4
// #1,counter=4
// #0,counter=4
// #1,counter=3
// #1,counter=2
// #2,counter=3
// #0,counter=3
// #1,counter=1
// #2,counter=2
// #0,counter=2
// #2,counter=1
// #0,counter=1

Runnable接口很简单,只有一个run方法。在创建Thread的时候,可以通过构造器指定一个Runnable实例作为线程绑定的任务,然后依旧调用Thread.start启动线程即可。

Executor

很多时候我们需要对线程进行管理,这时候像上面那样手动创建和启动线程就是不合适的,因此标准库提供了Executor作为线程创建和管理工具:

...
public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        for (int i = 0; i < 3; i++) {
            es.execute(new SimpleTask(i));
        }
    }
}

需要先通过Executors.newCachedThreadPool()方法获取一个ExecutorService实例,然后通过ExecutorService.execute()方法,传入一个Runnable实例以启动子线程。

ExecutorService可以理解为具有服务生命周期的Executor

newCachedThreadPool方法的字面意思就能看出,通过该方法创建的是一个“缓冲线程池”,所谓的“缓冲线程池”,是指JVM会根据情况创建一个合适大小的线程池(一般是任务数量),然后该线程池中的线程可以“重用”。也就是说当一个线程中的任务执行完毕,而存在其他等待执行的任务,就会使用该线程加载并执行。

线程池初始化实际上是多线程程序的重大开销之一,如果确定需要创建的线程池大小,是可以创建一个指定大小的线程池以提升效率的:

...
public class Main {
    public static void main(String[] args) {
        final int MAX_TASK = 3;
        ExecutorService es = Executors.newFixedThreadPool(MAX_TASK);
        for (int i = 0; i < MAX_TASK; i++) {
            es.execute(new SimpleTask(i));
        }
    }
}

示例中通过Executors.newFixedThreadPool方法创建了固定大小的线程池。

一般使用newCachedThreadPool就可以了。

从任务产生返回值

还可以通过Executor启动子任务并获取返回值:

package ch21.executor3;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

class Fibnacci {
    public static int fibnacci(int n) {
        if (n <= 2) {
            return 1;
        }
        return fibnacci(n - 1) + fibnacci(n - 2);
    }
}

class FibnacciTask implements Callable<Integer> {
    private int n;

    public FibnacciTask(int n) {
        this.n = n;
    }

    @Override
    public Integer call() throws Exception {
        return Fibnacci.fibnacci(n);
    }

}

public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        List<Future<Integer>> results = new ArrayList<>();
        for (int i = 1; i < 11; i++) {
            Future<Integer> result = es.submit(new FibnacciTask(i));
            results.add(result);
        }
        for (Future<Integer> result : results) {
            try {
                System.out.print(result.get() + " ");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
        }
    }
}
// 1 1 2 3 5 8 13 21 34 55 

这里是一个多线程打印斐波那契数列的示例,在这个例子中,通过实现Callable接口的方式创建任务,这个任务需要指定一个n表示在数列上的位置,然后通过call方法返回相应的结果。

要注意的是,Callable接口是一个泛型接口,其类型参数指的是call方法返回的数据类型。

main方法中,使用ExecutorService.submit方法启动子线程,并返回一个Future实例作为子线程调用的结果,这里对应Callable实例call方法的返回值。Future同样是一个泛型,其类型参数与call方法的返回值类型一致。

要明确的是,拿到Future引用并不意味着你可以立即获取到子线程的返回值,Future这个单词本身就意味着未来和不确定性,也就是说它相当一个“占位符”,必须要等到子线程执行完毕,确实返回结果后,你才能通过Future真正获取到结果。

通过Future.get方法可以阻塞式地获取到子线程的返回值。这也是为什么打印的结果是规整的1~10的斐波那契数列。实际上这个示例产生斐波那契数列的部分的确是多线程,但打印的部分实际上是单线程,由main进行。

这种方式使用结果是有性能损失的,考虑一下,假设n=2n=3的斐波那契数列先产生,但n=1的斐波那契数列没有,foreach的部分就会一直阻塞在第一个Future.get的地方。Go和Python都有一些更优化的标准组件,可以像队列那样持续获取一个执行完毕的Future对象,以避免因“卡住”而浪费性能。

这里其实还有一个问题,你可能会发现目前为止,凡是使用Executor运行的示例,都会在运行后无法正常退出。这可以通过在任务添加完毕后,调用ExecutorService.shutdown方法来解决:

...
public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        List<Future<Integer>> results = new ArrayList<>();
        for (int i = 1; i < 11; i++) {
            Future<Integer> result = es.submit(new FibnacciTask(i));
            results.add(result);
        }
        es.shutdown();
        ...
    }
}

ExecutorService.shutdown方法意味着子线程相关任务都已经启动,不再需要用ExecutorService启动其它子线程了。

休眠

可以通过Thread.sleep方法来"暂停"线程一段时间:

package ch21.sleep;
...
class SimpleTask implements Runnable {
	...
    @Override
    public void run() {
        do {
            System.out.println(this);
            counter--;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Thread.yield();
        } while (counter > 0);
    }
	...
}
...

Thread.sleep的参数时以毫秒为单位的整数,这里让子线程以1秒为间隔进行输出。

JavaSE 5开始,提供了一种更简洁明了的方式:

	...
	public void run() {
        do {
            System.out.println(this);
            counter--;
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Thread.yield();
        } while (counter > 0);
    }
    ...

通过TimeUnit类,可以很清晰地让线程休眠指定时间。

优先级

除了使用Thread.yield向调度器建议切换线程以外,还可以通过修改线程的优先级的方式来“影响”线程调度。

需要注意的是,影响并不意味着一定,最终决定依然是由线程调度器决定的。

package ch21.priority;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

import util.Fmt;

class SimpleTask implements Runnable {
    private final int id;
    private int counter = 5;

    public SimpleTask(int id) {
        this.id = id;
    }

    @Override
    public void run() {
        do {
            System.out.println(this);
            counter--;
            Thread.yield();
        } while (counter > 0);
    }

    @Override
    public String toString() {
        return Fmt.sprintf("#%d,counter=%d", id, counter);
    }

}

class HighPriorityThreadFactory implements ThreadFactory {

    @Override
    public Thread newThread(Runnable r) {
        Thread thread = new Thread(r);
        thread.setPriority(Thread.MAX_PRIORITY);
        return thread;
    }

}

public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        ExecutorService es2 = Executors.newCachedThreadPool(new HighPriorityThreadFactory());
        for (int i = 0; i < 6; i++) {
            if (i < 3) {
                es.execute(new SimpleTask(i));
            } else {
                es2.execute(new SimpleTask(i));
            }
        }
        es.shutdown();
        es2.shutdown();
    }
}

这里使用了ThreadFactory,可以在创建线程池时指定一个ThreadFactory,这样在调用ExecutorService.execute时通过该线程工厂来根据Runnable实例创建线程。

实例中的“线程工厂”用于修改线程的优先级,以让Runnable实例“附加”在高优先级的线程上。

如果查看输出,就可以发现虽然id<3的线程因为先启动的关系先输出,但是因为id>=3的线程优先级更高,它们反而获得更多的CPU执行机会,它们先执行完。

虽然具体的Java线程级别有10个,但是因为线程优先级是和操作系统直接相关的,所以具体程序的执行要考虑操作系统的线程优先级划分,而某些操作系统的优先级只有2~3个,就很难完整映射。因此实际编程中通常只会使用这三个值作为线程优先级:

  • Thread.MIN_PRIORITY
  • Thread.NORM_PRIORITY
  • Thread.MAX_PRIORITY

除了像上面这样指定线程优先级,还可以在线程中进行指定:

package ch21.priority2;
...
class SimpleTask implements Runnable {
	...
    @Override
    public void run() {
        this.preRun();
        do {
            System.out.println(this);
            counter--;
            Thread.yield();
        } while (counter > 0);
    }
	...
    protected void preRun() {
    }

}

class HighPriorityTask extends SimpleTask {
    public HighPriorityTask(int id) {
        super(id);
    }

    @Override
    protected void preRun() {
        Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
    }

}

public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        for (int i = 0; i < 6; i++) {
            if (i < 3) {
                es.execute(new SimpleTask(i));
            } else {
                es.execute(new HighPriorityTask(i));
            }
        }
        es.shutdown();
    }
}

为了能重用之前的代码,这里选择使用模版模式,通过在SimpleTask中加入preRun这个模版方法,子类HighPriorityTask可以覆盖该方法,并在其中通过Thread.currentThread()方法获取其所属线程的引用,然后用Thread.setPriority修改该线程的优先级。

需要注意的是,这种方式必须要确保Thread.currentThread的调用是发生在Runnablerun方法中,因为这部分的代码才是真正的子线程的范畴。如果在这个范围之外调用,你可能获取到的是main所在的主线程。

后台线程

前边已经提过,Java程序会在所有的前台线程执行完毕后退出,并且线程默认会是前台线程。

那么相应的,也存在后台线程(daemon,服务线程)。

Daemon这个词在Linux中指服务进程,相关内容可以阅读Linux 之旅 17:系统服务(daemons) - 魔芋红茶’s blog (icexmoon.cn)

看下面这个例子:

package ch21.daemon;
...
class SimpleTask implements Runnable {
	...
}

class DaemonThreadFactory implements ThreadFactory {

    @Override
    public Thread newThread(Runnable r) {
        Thread thread = new Thread(r);
        thread.setDaemon(true);
        return thread;
    }

}

public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool(new DaemonThreadFactory());
        for (int i = 0; i < 6; i++) {
            es.execute(new SimpleTask(i));
        }
        es.shutdown();
    }
}

相比之前的示例,这里只做了点小改动,DaemonThreadFactory同样是一个ThreadFactory,其主要用途是将任务所属线程设置为后台线程,具体是通过调用Thread.setDaemon方法实现。

如果你运行这个程序就会发现,不会有任何输出。

这是因为除了主线程,其余子线程都是后台线程,而主线程在es.shutdown()调用后,就会直接退出,此时子线程还没来得及执行,整个程序已经结束。这个过程和之前说过的Python以及Go的做法就很相似。

如果我们要观察子线程的输出,就需要让主线程等待一段时间:

...
public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService es = Executors.newCachedThreadPool(new DaemonThreadFactory());
        for (int i = 0; i < 6; i++) {
            es.execute(new SimpleTask(i));
        }
        es.shutdown();
        TimeUnit.SECONDS.sleep(5);
    }
}

编码技巧

对于简单的多线程,我们可以编写下面这样的“自运行”类:

package ch21.coding;

class SimpleTask extends Thread{
    public SimpleTask(){
        super();
        this.start();
    }

    @Override
    public void run() {
        System.out.println("task is running");
    }
}

public class Main {
    public static void main(String[] args) {
        new SimpleTask();
    }
}

也有使用Runnable的版本:

package ch21.coding2;

class SimpleTask implements Runnable {

    public SimpleTask() {
        Thread thread = new Thread(this);
        thread.start();
    }

    @Override
    public void run() {
        System.out.println("task is running.");
    }

}

public class Main {
    public static void main(String[] args) {
        new SimpleTask();
    }
}

需要提醒的是,在构造器中调用与初始化对象无关的代码是有风险的,这点在之前介绍构造器时提到过,详情见Java编程笔记5:多态 - 魔芋红茶’s blog (icexmoon.cn)。尤其是如果this.start这样的方法后还存在其他的初始化代码,就意味着你在线程对象没有完成初始化的情况下启动了这个线程。

当然,多线程编程同样可以使用内部类来组织代码:

package ch21.coding3;

class SimpleTask {
    private InnerCls ic = new InnerCls();
    {
        ic.start();
    }

    private static class InnerCls extends Thread {
        @Override
        public void run() {
            System.out.println("task is running.");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        new SimpleTask();
    }
}

这里的InnerClsSimpleTask的静态内部类,并且作为SimpleTask的属性ic存在。而启动线程的语句由一个初始化块{...}执行。这么做应该不常见,仅用于说明多种方式都可以用来启动线程。

如果SimpleTask中有多处使用了Inner,这样做对代码复用是有好处的,但如果仅像示例中这样有一处使用,更方便的是使用局部匿名类:

package ch21.coding4;

class SimpleTask {
    private Thread ic = new Thread() {
        @Override
        public void run() {
            System.out.println("task is running.");
        }
    };
    {
        ic.start();
    }
}
...

如果你只是想启动线程,连Thread句柄都不需要,可以:

package ch21.coding5;

class SimpleTask {
    public SimpleTask() {
        new Thread() {
            @Override
            public void run() {
                System.out.println("task is running.");
            }
        }.start();
    }
}
...

内部类结合Runnable接口的方式与之类似,这里不再展示。

合并线程

在多线程术语中,一个线程分出一个子线程一般称作分叉(fork),而两个线程合并成一个线程被称作合并(join)。

通过调用Thread.join,可以让当前线程等待目标线程,直到目标线程结束执行(此时目标线程的Thread.isAlive()方法将返回false)。

此外,join方法还可以指定一个参数作为超时时间(毫秒单位),如果超过该时间目标线程还没有结束执行,就不再等待。

阻塞式调用的 API 通常都会允许接收一个超时时间,以防止无限期阻塞下去。

看一个简单的示例:

package ch21.join;

import util.Fibnacci;

class SimpleTask extends Thread {
    @Override
    public void run() {
        for (int i = 1; i < 11; i++) {
            System.out.print(Fibnacci.fibnacci(i) + " ");
        }
        System.out.println();
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new SimpleTask();
        t.start();
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("program is exit.");
    }
}
// 1 1 2 3 5 8 13 21 34 55 
// program is exit.

子线程SimpleTask打印斐波那契数列,主线程调用Thread.join等待子线程执行结束,然后输出信息并退出。

你可能注意到Thread.join有一个异常声明InterruptedException,这是一个被检查异常(Checked Exception)。我们可以通过Thread.interruput方法强制终止目标线程,此时通过join方法等待该线程的线程就会捕获到该异常:

package ch21.join2;

import java.util.concurrent.TimeUnit;

import util.Fibnacci;

class SimpleTask extends Thread {
    @Override
    public void run() {
        for (int i = 1; i < 11; i++) {
            System.out.print(Fibnacci.fibnacci(i) + " ");
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                System.out.println();
                System.out.println("fibnacci print is interrupted.");
                return;
            }
        }
        System.out.println();
    }
}

class InterruptThread extends Thread{
    private Thread t;
    public InterruptThread(Thread t){
        this.t = t;
    }
    @Override
    public void run() {
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new SimpleTask();
        Thread t2 = new InterruptThread(t);
        t.start();
        t2.start();
        try {
            t.join();
        } catch (InterruptedException e) {
            System.out.println("SimpleTask is interrupted.");
        }
        System.out.println("program is exit.");
    }
}
// 1 1 2 
// fibnacci print is interrupted.
// program is exit.

看这个示例:

package ch21.join2;

import java.util.concurrent.TimeUnit;

import util.Fibnacci;

class SimpleTask extends Thread {
    @Override
    public void run() {
        for (int i = 1; i < 11; i++) {
            System.out.print(Fibnacci.fibnacci(i) + " ");
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                System.out.println();
                System.out.println("fibnacci print is interrupted.");
                System.out.println("isInterrupted() "+this.isInterrupted());
                return;
            }
        }
        System.out.println();
    }
}

class InterruptThread extends Thread{
    private Thread t;
    public InterruptThread(Thread t){
        this.t = t;
    }
    @Override
    public void run() {
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new SimpleTask();
        Thread t2 = new InterruptThread(t);
        t.start();
        t2.start();
        try {
            t.join();
        } catch (InterruptedException e) {
            System.out.println("SimpleTask is interrupted.");
        }
        System.out.println("program is exit.");
    }
}
// 1 1 2 
// fibnacci print is interrupted.
// isInterrupted() false
// program is exit.

这里为SimpleTask线程添加休眠,以延长执行时间。然后通过InterruptThread持有SimpleTask线程,并在特定时间调用Thread.interrupt方法中断SimpleTask线程的执行。相比SimpleTask线程中输出斐波那契数列的代码,显然其大部分时间都处于休眠状态,所以中断线程的时间也多半在其休眠时间,也就是说产生的InterruptedException异常会被包裹sleeptry...catch语句捕获,输出也说明了这一点。

线程有一个标识用于标记线程是否被中断,可以通过Thread.isInterrupted方法获取该标识,但是当线程中断产生的InterruptedException异常被捕获后,该标识会被重置为false,因此在catch语句中调用的isInterrupted返回false

关于中断标识被重置,可以这么理解:即使是try...catch这样的错误处理语句,也依然是SimpleTask的正常语句的一部分,可以看作是SimpleTask线程已从错误中恢复(这也正是Java异常机制设置的初衷)。

捕获异常

通常,如果子线程中存在异常,将直接输出到控制台,你没法在主线程中捕获他们:

package ch21.exception;

class ExceptionThread extends Thread {
    @Override
    public void run() {
        throw new RuntimeException("ExceptionThread's exp.");
    }
}

public class Main {
    public static void main(String[] args) {
        try {
            new ExceptionThread().start();
        } catch (Exception e) {
            System.out.println(e + " is caught.");
        }
    }
}
// Exception in thread "Thread-0" java.lang.RuntimeException: ExceptionThread's exp.
//         at ch21.exception.ExceptionThread.run(Main.java:6)

可以通过给线程添加“异常处理器”的方式添加线程的异常捕获和处理程序:

package ch21.exception2;

class ExceptionThread extends Thread {
    @Override
    public void run() {
        throw new RuntimeException("ExceptionThread's exp.");
    }
}

class ThreadExpHandle implements Thread.UncaughtExceptionHandler {

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println("thread exp " + e + " is caught.");
    }
}

public class Main {
    private static ExceptionThread exceptionThread;

    public static void main(String[] args) {
        exceptionThread = new ExceptionThread();
        exceptionThread.setUncaughtExceptionHandler(new ThreadExpHandle());
        exceptionThread.start();
    }
}
// thread exp java.lang.RuntimeException: ExceptionThread's exp. is caught.

异常处理器需要实现Thread.UncaughtExceptionHandler接口,并通过Thread.setUncaughtExceptionHandler方法“绑定”到线程。

如果是通过Executor启动多个线程,并需要全部绑定异常处理器,依然可以利用ThreadFactory接口:

package ch21.exception3;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

class ExceptionTask implements Runnable {
    @Override
    public void run() {
        throw new RuntimeException("ExceptionThread's exp.");
    }
}

class ThreadExpHandle implements Thread.UncaughtExceptionHandler {

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println("thread exp " + e + " is caught.");
    }
}

class ExpCaughtThreadFactory implements ThreadFactory {

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r);
        t.setUncaughtExceptionHandler(new ThreadExpHandle());
        return t;
    }

}

public class Main {

    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool(new ExpCaughtThreadFactory());
        for (int i = 0; i < 3; i++) {
            es.execute(new ExceptionTask());
        }
        es.shutdown();
    }
}
// thread exp java.lang.RuntimeException: ExceptionThread's exp. is caught.
// thread exp java.lang.RuntimeException: ExceptionThread's exp. is caught.
// thread exp java.lang.RuntimeException: ExceptionThread's exp. is caught.

实际上,如果程序中所有的线程都需要使用统一的异常处理器,可以通过Thread.setDefaultUncaughtExceptionHandler方法设置一个全局的默认异常处理器:

...
public class Main {

    public static void main(String[] args) {
        Thread.setDefaultUncaughtExceptionHandler(new ThreadExpHandle());
        ExecutorService es = Executors.newCachedThreadPool();
        for (int i = 0; i < 3; i++) {
            es.execute(new ExceptionTask());
        }
        es.shutdown();
    }
}

运行时,会先检查线程有无绑定特定的异常处理器,如果没有绑定,就会使用全局的默认异常处理器。

共享资源

多线程最大的麻烦是竞争,而竞争的关键在于共享资源(或数据竟态)。

关于共享资源引发的多线程冲突,可以通过一个简单的示例说明:

package ch21.share_data3;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class EvenGenerator {
    private int even = 0;
    private boolean isAlive = true;

    public int next() {
        even++;
        Thread.yield();
        even++;
        return even;
    }

    public boolean isAlive() {
        return isAlive;
    }

    public void cancel() {
        isAlive = false;
    }

}

public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        test(5, es);
        es.shutdown();
    }

    private static void test(int times, ExecutorService es) {
        EvenGenerator eg = new EvenGenerator();
        for (int i = 0; i < times; i++) {
            es.execute(new Runnable() {

                @Override
                public void run() {
                    while (eg.isAlive()) {
                        int even = eg.next();
                        if (even % 2 != 0) {
                            System.out.println(even + " is not even number.");
                            eg.cancel();
                        }
                    }
                }

            });
        }
    }
}
// 281 is not even number.
// 283 is not even number.
// 359 is not even number.

在这个例子中,EvenGeneratornext方法可以产生偶数并返回,在主线程中,创建一个EvenGenerator实例,并且创建若子线程来调用next方法,并检查这个返回值是否为偶数。

为了让读取偶数时是一个原子操作,这里选择让next返回一个int类型,而非Integer,因此无法让EvenGenerator实现Generator<Integer>接口。

因为子线程实际上是一个无限循环,所以为了实现一种退出机制,这里为EvenGenerator添加了一个cancel方法,可以将相应的标志位isAlive设置为false,如果子线程检测到相应的标志位为false,就退出循环并结束子线程。

为了让问题尽快暴露,这里在next方法中添加了一个Thread.yield方法,在两个自增语句之间建议调度器进行线程切换。

最终的结果会出现1条以上的输出,这同样是因为共享资源的缘故。虽然isAlive本身是一个boolean类型,对其赋值和读取是一个“原子操作”,但是JVM依然会使用一些缓存机制对数据访问进行优化,换句话说,其“可见性”依然是不确定的,所以一个子线程调用cancel后其它子线程并不会立即终止。当然,也可能其它子线程已经越过了while的条件检查语句。

更多基础的并发问题分析可以阅读Go语言编程笔记9:使用共享变量实现并发 - 魔芋红茶’s blog (icexmoon.cn)

synchronized

Java中,最简单解决共享资源问题的方式是对方法使用synchronized关键字:

package ch21.share_data4;
...
class EvenGenerator {
	...
    synchronized public int next() {
		...
    }

    public boolean isAlive() {
        return isAlive;
    }

    public void cancel() {
        isAlive = false;
    }

}
...

需要给多线程会“共享”的数据的相关方法都加上synchronized关键字,严格意义上来说,这里的evenisAlive都算共享数据,但实际上even更关键,对even的操作如果是“分裂”的,就会出现上边的问题。而isAlive是一个boolean类型,本质上只有简单的赋值和读取操作(是原子操作),如果不添加synchronized对相关操作同步,最多会导致其它线程“延迟退出”。而相应的,好处是不会存在并发瓶颈,毕竟大多数时间isAlive都是简单读取,这对性能是有好处的,所以综合考虑是不给isAlivecancel方法添加synchronized关键字。

synchronized关键字的用途是可以让方法调用在被多个线程调用时进行“同步”,换言之,和“互斥锁”的用途是类似的。实际上,调用synchronized声明的方法,会尝试对该方法所属的对象进行加锁,如果该锁已经被其它线程占用,就会阻塞。如果顺利获取锁,就会在该方法结束调用后释放锁。

需要注意的是,在Java中,通过synchronized关键字对对象进行加锁是可以在同一个线程中重复进行的,假设有这么一个共享资源:

class ShareData {
    private int data;

    synchronized public int increase() {
        data++;
        return getData();
    }

    synchronized public int getData() {
        return data;
    }
}

increase方法和getData都是synchronized的,且前者会调用后者。某个线程调用increase方法时,会对ShareData对象加锁,此时其它线程就不能对该对象加锁,也就是不能调用该对象的任何synchronized方法。但是,已经获取到锁(进入synchronized方法)的线程,就可以继续加锁。也就是在increase中调用getData方法,会导致ShareData对象的锁计数+1,getData方法调用结束后,锁计数-1,increase方法结束调用后,锁计数会归零,也就是锁被释放,此时其它线程可以调用该对象的synchronized方法。

Go语言的互斥锁就不支持重复加锁,即使是同一个线程也不允许,同一个线程中,对同一个互斥锁尝试重复加锁会导致“死锁”。具体可以阅读Go语言编程笔记9:使用共享变量实现并发 - 魔芋红茶’s blog (icexmoon.cn)

Lock

很多支持多线程的编程语言,都是通过“互斥锁”来解决共享资源问题的,Java也支持。

这里用互斥锁来改写之前的示例:

package ch21.share_data6;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class EvenGenerator {
    private int even = 0;
    private Lock lock = new ReentrantLock();
    private boolean isAlive = true;

    public int next() {
        lock.lock();
        try {
            even++;
            Thread.yield();
            even++;
            return even;
        } finally {
            lock.unlock();
        }
    }

    public boolean isAlive() {
        return isAlive;
    }

    public void cancel() {
        isAlive = false;
    }

}
...

Lock.lock方法可以加锁,Lock.unlock方法可以解锁。

通常,在对互斥锁加锁后,会使用一个try...finally语句,Lock.unlock会放在finally语句中,以确保任何情况都可以正常解锁,以避免死锁。需要注意的是,这里的return语句也是需要放在try块中的,因为even是共享资源,需要被锁保护(即使是在方法返回时)。

  • 在属性声明中,将锁声明放在被锁保护的属性后,是个良好编码习惯。
  • ReentrantLock是一个“可重入”(re-entrant)锁,也就是允许同一个线程重复加锁的意思,其作用方式和隐式加锁的synchronized方法是一致的,更多该类的说明见ReentrantLock (Java Platform SE 8 ) (oracle.com)

相比于synchronized关键字同步整个方法,互斥锁将同步的范畴缩小的被加锁和解锁的那一段代码,这样会带来更多的灵活性以及性能提升。

此外,Lock还支持非阻塞式地加锁:

package ch21.lock;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class TryLock {
    public Lock lock = new ReentrantLock();

    public void noTime() {
        if (lock.tryLock()) {
            try {
                System.out.println("noTime() get lock success.");
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("noTime() get lock failed.");
        }
    }

    public void timed() {
        try {
            if (lock.tryLock(1, TimeUnit.SECONDS)) {
                try {
                    System.out.println("timed() get lock success.");
                } finally {
                    lock.unlock();
                }
            } else {
                System.out.println("timed() get lock failed.");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class Main {
    public static void main(String[] args) {
        TryLock tl = new TryLock();
        ExecutorService es = Executors.newCachedThreadPool();
        testTryLock(tl, es);
        tl.lock.lock();
        testTryLock(tl, es);
        es.shutdown();
    }

    private static void testTryLock(TryLock tl, ExecutorService es) {
        Thread t1, t2;
        t1 = new Thread(){
            public void run() {
                tl.noTime();
            };
        };
        t2 = new Thread(){
            public void run() {
                tl.timed();
            };
        };
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
// noTime() get lock success.
// timed() get lock success.
// noTime() get lock failed.
// timed() get lock failed.

这里TryLock类中有一个互斥锁lock,方法noTime使用非阻塞的Lock.tryLock方法尝试加锁,该方法会返回一个boolean表示加锁成功还是失败,这里根据结果进行相应的打印,并且在加锁成功并打印后释放锁。timed方法的内容与noTime类似,不同的是使用的Time.tryLock方法包含两个参数,该参数指定的是一个等待时间,进程会等待相应的时间以获取锁,如果超过该时间,就直接返回失败。

在主线程中,testTryLock方法会启动两个子线程分别调用noTimetimed方法,并在之后通过Thread.join等待这两个子线程结束。第一次调用testTryLock会显示成功获取锁,而在主线程中显式加锁后再调用,就会显示两个方法加锁失败。

开始编写这个示例时,我试图让主线程休眠一段时间以等待两个子线程运行结束,结果发现这并不能确保主线程和子线程同步,最后只能用Thread.join这样的方式同步线程。

原子性和易变性

归根到底,共享资源的问题是因为在一个线程执行特定代码的“间隙”中发生了线程调度,其它线程执行的结果影响了这段代码的后续执行结果。

这里就存在一个问题,如果是两行代码,那显而易见的是,这两行代码之间是可能发生线程切换的,但如果是一行代码呢?

这个问题被称作“代码执行的原子性(atomic)”问题。

所有的高级语言,在实际执行中,都会被翻译为汇编这样的机器语言,以机器指令的方式执行。而作为最小CPU执行单位的一条汇编指令,就是原子性的。从开始执行这条指令到结束,都是不可能发生线程切换的。

换言之,如果一行Java代码,其翻译后的机器指令是一条,那么这行代码就是原子性的,你不用担心在执行这行代码之间发生线程切换引起的并发问题。

具体来说,在Java中除了4字节的longdouble这两种基础类型以外,其余基础类型的赋值和读取操作都是原子性的。JVM会将longdouble的读写操作分为32位操作的两个步骤来完成,因此它们不是原子性的。所以理论上对longdouble进行读写期间,也是可以发生线程切换的,这可能导致错误的结果(被称作“字撕裂”)。

可以通过volatile关键字让longdouble类型的数据(读写操作)获得原子性。

除了原子性以外,易变性也会影响并发编程的结果。

所谓的易变性,是指在并发编程中,处于优化的考虑,JVM会对多个线程操作的同一个数据使用缓存。也就是说有时候某个线程修改了一个数据,此时修改的只是该数据在当前线程中的缓存,而这种修改并不会立即回写到主存中的原始数据上。而其它线程就没法及时从主存中获取到正确的数据。

volatile关键字的另一个作用就是影响数据的易变性,被声明为volatile的数据,被修改后会立即回写到主存,无论是否使用了缓存技术。

资深的多线程开发者,可以利用原子性来编写不使用互斥锁,或者少量使用互斥锁的并发代码,但对于一般开发者来说,这样做是危险的,更稳妥的方法依然是使用互斥锁或synchronized

关于要有多资深才可以,《Tinking in Java》给出的解释是——要可以编写一个Java虚拟机。

看一个示例:

package ch21.atomic;

import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class IntGenerator {
    private static volatile int num;

    public static int next() {
        return num++;
    }
}

class CircularSet {
    private int[] content;
    private int index;

    public CircularSet(int size) {
        content = new int[size];
        Arrays.fill(content, -1);
    }

    public synchronized boolean add(int num) {
        if (contains(num)) {
            return false;
        }
        content[index] = num;
        index++;
        index = index % content.length;
        return true;
    }

    public synchronized boolean contains(int num) {
        for (int i = 0; i < content.length; i++) {
            if (num == content[i]) {
                return true;
            }
        }
        return false;
    }
}

public class Main {
    public static void main(String[] args) {
        final int SIZE = 1000;
        CircularSet cs = new CircularSet(SIZE);
        ExecutorService es = Executors.newCachedThreadPool();
        for (int i = 0; i < 10; i++) {
            es.execute(new Runnable() {
                @Override
                public void run() {
                    while (true) {
                        int num = IntGenerator.next();
                        Boolean result = cs.add(num);
                        if (!result) {
                            System.out.println(num + " is already in set.");
                            break;
                        }
                    }
                }
            });
        }
    }
}
// 1565 is already in set.
// 3233 is already in set.
// 35421 is already in set.
// 39888 is already in set.
// 37317 is already in set.
// 37034 is already in set.
// 35852 is already in set.
// 50176 is already in set.
// 53190 is already in set.
// 53191 is already in set.

IntGenerator有一个类属性num,被标注为volatile,所以它具有原子性和易变性。静态方法next内容很简单,让num自增后返回。

需要注意的是,自增操作本身在Java中并非原子性的。

要验证这一点,我们需要一个集合来保存多个线程调用next不断生成的数字,如果产生的数字重复,就说明next方法中的语句是非原子性的,在某个中间状态进行了返回。

如果使用普通的Set容器,很可能因为无限写入数据耗光内存,所以这里用一个“循环Set”CircularSet来写入数据。当然,因为是多个线程共享的,所以相关的公开方法需要添加synchronized关键字。

最终的输出可以发现,产生重复数字的概率很高。这表明即使是原子性的数据,也可能因为操作本身的非原子性而产生并发问题。

解决的方式也很简单:

...
class IntGenerator {
    private static volatile int num;

    public synchronized static int next() {
        return num++;
    }
}
...

next方法添加synchronized关键字即可。

原子类

Java SE 5增加了一些“原子类”,如AtomicIntegerAtomicLong等。使用这些原子类的好处在于,相比较volatile修饰的基础类型,原子类的相关操作也是原子性的。

比如,使用原子类改写之前的示例:

package ch21.atomic3;

import java.util.Arrays;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

class IntGenerator {
    private static AtomicInteger ai = new AtomicInteger();

    public static int next() {
        return ai.incrementAndGet();
    }
}
...
public class Main {
    public static void main(String[] args) {
        new Timer().schedule(new TimerTask() {

            @Override
            public void run() {
                System.out.println("Aborting.");
                System.exit(0);
            }

        }, 5000);
        ...
    }
}
// Aborting.

可以看到和使用同步的效果相同,都不会产生重复数据,这是因为原子类AtomicInteger的自增和返回操作incrementAndGet是原子性的,所以即使没有对next方法同步,也不会出现问题。

为了让程序在有限时间内退出,这里添加了一个特定时间后启动的任务调度new Timer().schedule,这种使用方式在Android编程中很常见。

临界区

我们之前介绍和比较了synchronized关键字和互斥锁两种共享资源的优缺点,前者相比后者来说,性能要差点,但使用更方便。

相比于对整个方法使用synchronized进行同步,还可以只对方法中的一部分代码使用synchronized关键字进行同步,此时被synchronized关键字保护的这部分代码被称作临界区(critical section)。

具体的语法是:

synchronized(obj){
	...
}

其中obj指的是用于隐式加锁的某个对象,在实际编码中一般使用当前对象(this),这样做可以和其它用synchronized同步的方法协同。

这种方式给我们提供了更多的灵活性,可以让我们像使用互斥锁那样只对关键的受并发影响的代码进行同步以提高性能。

下面这个示例对两种方式进行比较:

package ch21.critical;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import util.Fmt;

class Pair {
    private int x;
    private int y;

    public Pair(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public Pair() {
        this(0, 0);
    }

    public void increaseX() {
        x++;
    }

    public void increaseY() {
        y++;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    @Override
    public String toString() {
        return Fmt.sprintf("Pair(%d,%d)", x, y);
    }

    public class PairValueNotEqualException extends RuntimeException {
        public PairValueNotEqualException() {
            super("Pair vlaue not equal:" + Pair.this);
        }
    }

    public void check() {
        if (x != y) {
            throw new PairValueNotEqualException();
        }
    }
}

abstract class PairManager {
    public AtomicInteger checkCounter = new AtomicInteger();
    protected List<Pair> pairs = Collections.synchronizedList(new ArrayList<>());
    protected Pair pair = new Pair();

    public synchronized Pair getPair() {
        return new Pair(pair.getX(), pair.getY());
    }

    protected void store(Pair pair) {
        pairs.add(pair);
        try {
            TimeUnit.MILLISECONDS.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public abstract void increase();

    @Override
    public synchronized String toString() {
        return Fmt.sprintf("%s, check counter:%d", pair, checkCounter.get());
    }
}

class PairManger1 extends PairManager {

    @Override
    public synchronized void increase() {
        pair.increaseX();
        pair.increaseY();
        store(getPair());
    }

}


class PairManager3 extends PairManager {

    @Override
    public void increase() {
        Pair pairCopy;
        synchronized (this) {
            pair.increaseX();
            pair.increaseY();
            pairCopy = getPair();
        }
        store(pairCopy);
    }

}

class PairIncrseTask implements Runnable {
    PairManager pairManager;

    public PairIncrseTask(PairManager pairManager) {
        this.pairManager = pairManager;
    }

    @Override
    public void run() {
        while (true) {
            pairManager.increase();
        }
    }

}

class PairCheckTask implements Runnable {
    PairManager pairManager;

    public PairCheckTask(PairManager pairManager) {
        this.pairManager = pairManager;
    }

    @Override
    public void run() {
        while (true) {
            pairManager.getPair().check();
            pairManager.checkCounter.incrementAndGet();
        }
    }

}

public class Main {
    public static void main(String[] args) {
        PairManager pm1, pm3;
        pm1 = new PairManger1();
        pm3 = new PairManager3();
        ExecutorService es = Executors.newCachedThreadPool();
        startPMCheck(pm1, es);
        startPMCheck(pm3, es);
        es.shutdown();
        try {
            TimeUnit.MICROSECONDS.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("pm1:" + pm1);
        System.out.println("pm3:" + pm3);
        System.exit(0);
    }

    private static void startPMCheck(PairManager pm, ExecutorService es) {
        es.execute(new PairIncrseTask(pm));
        es.execute(new PairCheckTask(pm));
    }

}
// pm1:Pair(1,1), check counter:62
// pm3:Pair(3,3), check counter:3776422

Pair是一个简单的类,包含xy两个属性,分别可以使用increaseXincreaseY进行自增,并可以使用check方法检查当前两个属性是否相等,如果不相等则抛出一个特定异常。

Pair这个类没有使用任何方式保证线程安全,因此它是线程不安全的。但我们可以借助一个中间类PairManager来将其用于多线程。

PairMangaer持有一个Pair对象,所有对这个对象的操作都通过PairManager的方法进行,而PairManager的相关方法都是同步的,因此这样做是线程安全的。

需要注意的是,PairManager.getPair将返回一个Pair对象的拷贝,而不是直接返回Pair.pair的引用。这样做是有意义的,因为Pair本身是线程不安全的,所以原始的Pair.pair必须对其它线程隐藏,只返回给它们拷贝。

这里为了比较使用临界区和对整个方法同步的差异,increase方法被设置为抽象方法,两个子类PairManger1PairManager3分别用两种方式来实现increase方法。这里关键的操作是分别调用Pair.increaseXPair.increaseY以让xy同时自增以保持相等,最后的PairManager.store的调用仅是为了模拟一个耗时操作以控制最终的模拟数据结果,本身没有什么意义。

最后还创建了两个任务PairIncrseTaskPairCheckTask,一个任务负责调用PairManager.increase自增,另一个负责调用PairManager.getPair().check检查自增过程是否有问题。

这里虽然Pair.check()是线程不安全的,但因为PairManager.getPair方法返回的是一个仅会被当前进程使用的拷贝,不是共享数据,所以就不存在共享资源的问题。

为了观察检查频率的差异,这里用一个原子类作为PairManager的计数变量,并在PairCheckTask每次检查后递增,最后main方法中进行了模拟测试。为了在有限时间内结束,这里让主线程休眠一段时间后强制退出程序。

从结果看,使用临界区的代码性能要好的多。

当然也可以使用互斥锁实现上面的示例,但是需要注意的是,因为synchronized是对当前对象隐式加锁,所以不能同时使用synchronized方法和互斥锁,因为那样实际上是使用了两种互斥锁进行并发控制,会导致多个线程同时使用共享资源的情况出现。因此如果要修改上边的示例为互斥锁的版本,就需要对整个PairManager类进行修改:

package ch21.critical2;
...
abstract class PairManager {
    public AtomicInteger checkCounter = new AtomicInteger();
    protected List<Pair> pairs = Collections.synchronizedList(new ArrayList<>());
    protected Pair pair = new Pair();
    protected Lock lock = new ReentrantLock();

    public Pair getPair() {
        lock.lock();
        try {
            return new Pair(pair.getX(), pair.getY());
        } finally {
            lock.unlock();
        }
    }

    protected void store(Pair pair) {
        pairs.add(pair);
        try {
            TimeUnit.MILLISECONDS.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public abstract void increase();

    @Override
    public String toString() {
        lock.lock();
        try {
            return Fmt.sprintf("%s, check counter:%d", pair, checkCounter.get());
        } finally {
            lock.unlock();
        }
    }
}


class PairManager2 extends PairManager {

    @Override
    public void increase() {
        Pair pairCopy;
        lock.lock();
        try {
            pair.increaseX();
            pair.increaseY();
            pairCopy = getPair();
        } finally {
            lock.unlock();
        }
        store(pairCopy);
    }

}
...

在其它对象上同步

正如我们之前说的,使用临界区的时候使用this可以和其它synchronized方法协同:

package ch21.sync_other;

class ShareData {
    public synchronized void func1() {
        for (int i = 0; i < 5; i++) {
            System.out.println("func1() is called.");
            Thread.yield();
        }
    }

    public void func2() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println("func2() is called.");
                Thread.yield();
            }
        }
    }
}

public class Main {
    public static void main(String[] args) {
        ShareData shareData = new ShareData();
        new Thread() {
            public void run() {
                shareData.func1();
            };
        }.start();
        new Thread() {
            public void run() {
                shareData.func2();
            };
        }.start();
    }
}
// func1() is called.
// func1() is called.
// func1() is called.
// func1() is called.
// func1() is called.
// func2() is called.
// func2() is called.
// func2() is called.
// func2() is called.
// func2() is called.

这个示例中func1是一个synchronized方法,func2使用this定义的临界区。使用两个线程分别执行这两个方法,可以看到它们是“互斥”的,同时只能有一个方法被执行,因为这两者实际上都是使用了当前对象作为互斥锁来实现的。

但如果临界区用其它对象指定,就不会有类似的现象:

package ch21.sync_other2;

class ShareData {
    private final Object obj = new Object();
	...
    public void func2() {
        synchronized (obj) {
            for (int i = 0; i < 5; i++) {
                System.out.println("func2() is called.");
                Thread.yield();
            }
        }
    }
}
...
// func1() is called.
// func1() is called.
// func2() is called.
// func1() is called.
// func2() is called.
// func1() is called.
// func2() is called.
// func1() is called.
// func2() is called.
// func2() is called.

这里和上边示例的唯一区别是使用ShareData.obj属性指定临界区,所以实际上和synchronized方法使用的互斥锁已经不同了,最终的结果也说明func1func2被同时调用。

线程本地存储

解决资源共享的终极方法就是让线程不要共享资源,这看似是一句废话,但实际上可以借助一些技术来实现。比如线程本地存储(Thread Local)。

看下面这个使用了线程本地存储的例子:

package ch21.local;

import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import util.Fmt;

class IntegerGenerator {
    private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>() {
        private static Random rand = new Random();

        protected Integer initialValue() {
            return Integer.valueOf(rand.nextInt(100));
        };
    };

    public static void increase() {
        threadLocal.set(threadLocal.get() + 1);
    }

    public static int next() {
        increase();
        return threadLocal.get();
    }

    public static int get() {
        return threadLocal.get();
    }
}

class NextTask implements Runnable {
    private int id;

    public NextTask(int id) {
        this.id = id;
    }

    @Override
    public void run() {
        while (true) {
            IntegerGenerator.next();
            System.out.println(this);
            Thread.yield();
        }
    }

    @Override
    public String toString() {
        return Fmt.sprintf("Task#%d(%d)", id, IntegerGenerator.get());
    }

}

public class Main {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        for (int i = 0; i < 3; i++) {
            es.execute(new NextTask(i));
        }
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.exit(0);
    }
}
// Task#0(14793)
// Task#1(14830)
// Task#2(14862)
// Task#1(14831)
// Task#0(14794)
// Task#1(14832)
// Task#2(14863)

这里线程共享的数据是IntegerGenerator.threadLocal,这是一个静态属性,并且通过IntegerGenerator的其它静态方法进行操作。

比较特别的是,IntegerGenerator.threadLocal是一个ThreadLocal类型的数据,这是一个线程本地存储的类型。仅可以通过ThreadLocal.getThreadLocal.set两个方法获取和修改其中的值。并且可以在创建时通过匿名类重写initialValue方法的方式让其具备一个初始值。

这里的关键在于每个线程使用ThreadLocal实例时,实际上使用的是为当前线程“服务”而创建的特异化实例。也就是说假设3个线程访问IntegerGenerator.threadLocal,其实内存中存在的是三个不同的IntegerGenerator.threadLocal实例,分别对应三个线程。也就是上面说的,从最根本上解决了数据竟态的问题。相应的,调用ThreadLocal.get获取到的是当前线程对应的ThreadLocal中的值。而ThreadLocal.set也仅修改当前线程对应的ThreadLocal中的值。

打印结果也说明了这一点。

并发是一个庞大的议题,所以可能会拆分成多篇笔记,这里先到这里,谢谢阅读。

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值