8、Java多线程


一、多线程基础知识

现代操作系统(Windows,macOS,Linux)都可以执行多任务。多任务就是同时运行多个任务,例如:

CPU 执行代码都是一条一条顺序执行的,但是,即使是单核 cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让 CPU 对多个任务轮流交替执行。
例如,假设我们有语文、数学、英语 3 门作业要做,每个作业需要 30 分钟。我们把这 3 门作业看成是 3 个任务,可以做 1 分钟语文作业,再做 1 分钟数学作业,再做 1 分钟英语作业:
这样轮流做下去,在某些人眼里看来,做作业的速度就非常快,看上去就像同时在做 3 门作业一样
类似的,操作系统轮流让多个任务交替执行,例如,让浏览器执行 0.001 秒,让 QQ 执行 0.001 秒,再让音乐播放器执行 0.001 秒,在人看来,CPU 就是在同时执行多个任务。
即使是多核 CPU,因为通常任务的数量远远多于 CPU 的核数,所以任务也是交替执行的。
1.进程
在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和 Word 都是进程。
某些进程内部还需要同时执行多个子任务。例如,我们在使用 Word 时,Word 可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。
进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

操作系统调度的最小任务单位其实不是进程,而是线程。常用的 Windows、Linux 等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。
因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:
多进程模式(每个进程只有一个线程):

多线程模式(一个进程有多个线程):

多进程+多线程模式(复杂度最高):

2.进程 vs 线程
进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。
具体采用哪种方式,要考虑到进程和线程的特点。
和多线程相比,多进程的缺点在于:

  • 创建进程比创建线程开销大,尤其是在 Windows 系统上;
  • 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。

多进程的优点在于:
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
那么什么时候使用多进程,什么时候使用多线程呢?

  • 耗时的 I/O 操作选择多线程(线程之间切换速度极快)
  • 密集的科学计算选择多进程(密集计算是消耗 CPU 的过程,切换线程反而影响计算速度)

3.多线程
Java 语言内置了多线程支持:一个 Java 程序实际上是一个 JVM 进程,JVM 进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM 还有负责垃圾回收的其他工作线程等。
因此,对于大多数 Java 程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。
和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。
Java 多线程编程的特点又在于:

  • 多线程模型是 Java 程序最基本的并发模型;
  • 后续读写网络、数据库、Web 开发等都依赖 Java 多线程模型。

因此,必须掌握 Java 多线程编程才能继续深入学习其他内容。

二、创建多线程

Java 语言内置了多线程支持。当 Java 程序启动的时候,实际上是启动了一个 JVM 进程,然后,JVM 启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。
java 提供了 Thread 类和 Runnable 接口两种方法来帮我们完成多线程的程序,我们需要实例化一个Thread实例,然后调用它的start()方法:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread();
        t.start(); // 启动新线程
    }
}

但是这个线程启动后实际上什么也不做就立刻结束了。我们希望新线程能执行指定的代码,有以下几种方法:

1.继承 Thread

方法一:从Thread派生一个自定义类,然后覆写run()方法:

public class Main {
    public static void main(String[] args) {
        System.out.println("start main thread!");
        Thread t = new MyThread();
        t.start(); // 启动新线程
        System.out.println("start end thread!");
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

执行效果:

执行上述代码,注意到start()方法会在内部自动调用实例的run()方法。

2.实现 Runnable 接口


方法二:创建Thread实例时,传入一个Runnable实例:

public class Main {
    public static void main(String[] args) {
        MyRunnable myRun = new MyRunnable();	//3
        Thread t1 = new Thread(myRun,"线程A");	//4		可以指定线程名字
        t1.start(); // 5启动新线程
        Thread t2 = new Thread(myRun,"线程B");	//4		可以指定线程名字
        t2.start(); // 5启动新线程
    }
}
//1
class MyRunnable implements Runnable {
    @Override
    public void run() {	// 2
      //System.out.println("当前线程是:"+getName());	//不能直接用getName() 这里没有继承Thread
        System.out.println("我是线程-"+Thread.currentThread().getName());	//先拿到当前线程
        System.out.println("start new thread!");
    }
}

相比继承 Thread 类,实现 Runnable 接口的好处:

  • 避免了 Java 单继承的局限性;
  • 适合多个线程去处理同一个资源的情况(多个线程共享同一个接口实例,实例中的变量值共享)

3.多线程的优势

用 Java8 引入的 lambda 语法进一步简写为:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("start new thread!");
        });
        t.start(); // 启动新线程
    }
}

有童鞋会问,使用线程执行的打印语句,和直接在main()方法执行有区别吗?
区别大了去了。我们看以下代码:

public class Main {
    public static void main(String[] args) {
        System.out.println("main start...");
        Thread t = new Thread() {
            public void run() {
                System.out.println("thread run...");
                System.out.println("thread end.");
            }
        };
        t.start();
        System.out.println("main end...");
    }
}

我们用蓝色表示主线程,也就是main线程,main线程执行的代码有 4 行,首先打印main start,然后创建Thread对象,紧接着调用start()启动新线程。当start()方法被调用时,JVM 就创建了一个新线程,我们通过实例变量t来表示这个新线程对象,并开始执行。
接着,main线程继续执行打印main end语句,而t线程在main线程执行的同时会并发执行,打印thread runthread end语句。
run()方法结束时,新线程就结束了。而main()方法结束时,主线程也结束了。
我们再来看线程的执行顺序:

  1. main线程肯定是先打印main start,再打印main end
  2. t线程肯定是先打印thread run,再打印thread end

但是,除了可以肯定,main start会先打印外,main end打印在thread run之前、thread end之后或者之间,都无法确定。因为从t线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序。
要模拟并发执行的效果,我们可以在线程中调用Thread.sleep(),强迫当前线程暂停一段时间:

public class Main {
    public static void main(String[] args) {
        System.out.println("main start...");
        Thread t = new Thread() {
            public void run() {
                System.out.println("thread run...");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {}
                System.out.println("thread end.");
            }
        };
        t.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {}
        System.out.println("main end...");
    }
}

打印结果:

main start...
thread run...
main end...
thread end.

sleep()传入的参数是毫秒。调整暂停时间的大小,我们可以看到main线程和t线程执行的先后顺序。
要特别注意:直接调用Thread实例的run()方法是无效的:

public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.run();
    }
}
class MyThread extends Thread {
    public void run() {
        System.out.println("hello");
    }
}

直接调用run()方法,相当于调用了一个普通的 Java 方法,当前线程并没有任何改变,也不会启动新线程。上述代码实际上是在main()方法内部又调用了run()方法,打印hello语句是在main线程中执行的,没有任何新线程被创建。
必须调用Thread实例的start()方法才能启动新线程,如果我们查看Thread类的源代码,会看到start()方法内部调用了一个private native void start0()方法,native修饰符表示这个方法是由 JVM 虚拟机内部的 C 代码实现的,不是由 Java 代码实现的。
多线程执行一个不相互依赖的方法时的优势:

4.线程相关方法

t1.start()    //启动线程,自动执行run()方法
run()		//继承Thread类必须要重写的方法

t1.getName()	//返回线程的名称	系统默认线程名称是 Thread-0  Thread-1 。。。
t1.setName()	//设置线程名称
currentThread()		//返回当前线程

线程的优先级
可以对线程设定优先级,设定优先级的方法是:

t1.getPriority()	//获取线程优先级,默认值5
t1.setPriority(int n) 	// 1~10, 数值越大优先级越高

优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
yield:线程让步

Thread.yield()		//线程让步
  • 暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程
  • 若队列中没有同优先级的线程,忽略此方法

join():线程等待
当主线程调用 t1.join() ,主线程需要等待 t1 线程结束,才能执行后面程序
stop():强制结束线程
isAlive():判断线程是否还活着,返回 boolean

5.练习

public class Test1 {
    public static void main(String[] args) {
        System.out.println("start main thread!");
        Thread t = new MyThread();
        t.start(); // 启动新线程
        for(int i = 0; i < 100; i++){
            System.out.println("我是主线程:"+i);
        }
        System.out.println("start end thread!");
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start new thread!");
        for (int i=0;i<100;i++){
            System.out.println("new thread---"+i);
        }
    }
}

打印结果:

发现主线程和子线程是交替运行的
小结:

  • Java 用Thread对象表示一个线程,通过调用start()启动一个新线程;
  • 一个线程对象只能调用一次start()方法;
  • 线程的执行代码写在run()方法中;
  • 线程调度由操作系统决定,程序本身无法决定调度顺序;
  • Thread.sleep()可以把当前线程暂停一段时间。

三、线程状态

在 Java 程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。因此,Java 线程的状态有以下几种:

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的 Java 代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

用一个状态转移图表示如下:

当线程启动后,它可以在RunnableBlockedWaitingTimed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。
线程终止的原因有:

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)。

一个线程还可以等待另一个线程直到其运行结束。例如,main线程在启动t线程后,可以通过t.join()等待t线程结束后再继续运行:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i=0;i<1000;i++){
                    System.out.println(super.getName()+":"+i);
                }
            System.out.println("hello");
        });
        System.out.println("start");
        t.start();
        t.join(1000);	//等待1s
        System.out.println("end");
    }
}

main线程对线程对象t调用join()方法时,主线程将等待变量t表示的线程运行结束,即join就是指等待该线程结束,然后才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是main线程先打印startt线程再打印hellomain线程最后再打印end
如果t线程已经结束,对实例t调用join()会立刻返回。此外,join(long)的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。
小结:

  • Java 线程对象Thread的状态包括:NewRunnableBlockedWaitingTimed WaitingTerminated
  • 通过对另一个线程对象调用join()方法可以等待其执行结束;
  • 可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;
  • 对已经运行结束的线程调用join()方法会立刻返回。

四、中断线程

如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()方法,使得自身线程能立刻结束运行。
我们举个栗子:假设从网络下载一个 100M 的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。
中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法,目标线程需要反复检测自身状态是否是 interrupted 状态,如果是,就立刻结束运行。
我们还是看示例代码:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(1); 	// 暂停1毫秒
        t.interrupt(); 	// 中断t线程
        t.join(); 	// 等待t线程结束
        System.out.println("end");
    }
}
class MyThread extends Thread {
    public void run() {
        int n = 0;
        while (!isInterrupted()) {	//非中断执行
            n ++;
            System.out.println(n + " hello!");
        }
    }
}

仔细看上述代码,main线程通过调用t.interrupt()方法中断t线程,但是要注意,interrupt()方法仅仅向t线程发出了“中断请求”,至于t线程是否能立刻响应,要看具体代码。而t线程的while循环会检测isInterrupted(),所以上述代码能正确响应interrupt()请求,使得自身立刻结束运行run()方法。
如果线程处于等待状态,例如,t.join()会让main线程进入等待状态,此时,如果对main线程调用interrupt()join()方法会立刻抛出InterruptedException,因此,目标线程只要捕获到join()方法抛出的InterruptedException,就说明有其他线程对其调用了interrupt()方法,通常情况下该线程应该立刻结束运行。
我们来看下面的示例代码:

public class Test3 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread3();
        t.start();
        Thread.sleep(1000);
        t.interrupt(); // 中断t线程
        t.join(); // 等待t线程结束
        System.out.println("end");
    }
}
class MyThread3 extends Thread {
    @Override
    public void run() {
        Thread hello = new HelloThread();
        hello.start(); // 启动hello线程
        try {
            hello.join(); // 等待hello线程结束
        } catch (InterruptedException e) {
            System.out.println("interrupted!");
        }
        hello.interrupt();
    }
}
class HelloThread extends Thread {
    @Override
    public void run() {
        int n = 0;
        while (!isInterrupted()) {
            n++;
            System.out.println(n + " hello!");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                System.out.println("hello发出中断请求");
                break;		//
            }
        }
    }
}

执行效果:

1 hello!
2 hello!
3 hello!
4 hello!
5 hello!
6 hello!
7 hello!
8 hello!
9 hello!
10 hello!
interrupted!
end
hello发出中断请求

main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。在t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且 JVM 不会退出。
另一个常用的中断线程的方法是设置标志位。我们通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束:

public class Main {
    public static void main(String[] args)  throws InterruptedException {
        HelloThread t = new HelloThread();
        t.start();
        Thread.sleep(1);
        t.running = false; // 标志位置为false
    }
}
class HelloThread extends Thread {
    public volatile boolean running = true;
    public void run() {
        int n = 0;
        while (running) {
            n ++;
            System.out.println(n + " hello!");
        }
        System.out.println("end!");
    }
}

注意到HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字volatile声明?这涉及到 Java 的内存模型。在 Java 虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!

这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程 1 执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在 JVM 把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。
因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在 x86 的架构下,JVM 回写主内存的速度非常快,但是,换成 ARM 的架构,就会有显著的延迟。
Java 中 Volatile 关键字详解:https://www.cnblogs.com/zhengbin/p/5654805.html
小结:

  • 对目标线程调用interrupt()方法可以请求中断一个线程,目标线程通过检测isInterrupted()标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException
  • 目标线程检测到isInterrupted()true或者捕获了InterruptedException都应该立刻结束自身线程;
  • 通过标志位判断需要正确使用volatile关键字;
  • volatile关键字解决了共享变量在线程间的可见性问题。

五、守护线程

Java 程序入口就是由 JVM 启动main线程,main线程又可以启动其他线程。如果有一个线程没有退出,JVM 进程就不会退出。现在有这么个需求某个线程作为大哥 其他线程是小弟,大哥死掉之后 小弟也都一哄而散:
刘备:

public class ThreadDaemon2 extends Thread{
    @Override
    public void run() {
        for (int i=0;i<3;i++){
            System.out.println("当前线程是:"+this.getName()+" ,i="+i);
        }
    }
}

关羽 张飞:

public class ThreadDaemon2 extends Thread{
    @Override
    public void run() {
        for (int i=0;i<100;i++){
            System.out.println("当前线程是:"+this.getName()+" ,i="+i);
        }
    }
}

主程序:

public class ThreadDaemonDemo {
    public static void main(String[] args) {
        ThreadDaemon2 t1 = new ThreadDaemon2();
        ThreadDaemon t2 = new ThreadDaemon();
        ThreadDaemon t3 = new ThreadDaemon();
        //为线程设置名称
        t1.setName("刘备");
        t2.setName("关羽");
        t3.setName("张飞");
        //设置守护线程
        t2.setDaemon(true);
        t3.setDaemon(true);
        //开启线程
        t1.start();
        t2.start();
        t3.start();
        System.out.println("main主线程结束");
    }
}


当刘备死掉之后,关羽 张飞作为守护线程也结束.

六、线程同步

什么是线程同步呢? 我们先考虑这样一个事情, 我们去银行取钱, 可以选择柜台取钱, 也可以选择去 ATM 机取钱. 对吧. 但是不论哪种方式和方案去取钱, 最终操作的都是同一个账户. 那我们想一个问题. 如果我可以同时在 ATM 和柜台取钱….. 会发生什么呢?

如果真的很赶巧的话, 想想, ATM 取走 1000, 柜台取走 1000, 此时如果没有任何拦截的话, 对于 ATM 和柜台而言看到的钱都是 1000, 取 1000 可以. 没毛病, 但是, 一共取走了 2000 块. 你的账户余额就变成了-1000. 是吧.

1.取钱案例

账户(最关键)

public class Account {
    private double balance;
    private volatile int count = 0;
    public Account(double balance){
        this.balance = balance;
    }
    public double getBalance(){
        return balance;
    }
    public void getMoney(){
        if(balance <= 0){
            System.out.println("余额不足!");
            return;
        }
        Date d1 = new Date();
        System.out.println("时间:"+d1.getTime()+",第"+count+"次取钱,余额是"+balance);
        count++;    //判断是第几次取钱
        balance-=1000;
        System.out.println("时间:"+d1.getTime()+",第"+count+"次取钱,余额是"+balance);
    }
}

ATM 取款:

public class ATMThread extends Thread {
    private Account acc;
    public ATMThread(Account acc){
        this.acc = acc;
    }
    @Override
    public void run() {
        acc.getMoney();     //ATM取钱
    }
}

柜台取款:

public class GuiTaiThread extends Thread{
    private Account acc;
    public GuiTaiThread(Account acc){
        this.acc = acc;
    }
    @Override
    public void run() {
        acc.getMoney();     //ATM取钱
    }
}

主程序:

ccount acc = new Account(1000);
ATMThread atm = new ATMThread(acc);
GuiTaiThread gtt = new GuiTaiThread(acc);
atm.start();
gtt.start();

效果:

别介意 1,2 的顺序问题. CPU 调度是随机的。 但是, 我们能清楚的看到余额变成了-1000. 上面我们做的 if 判断并没有生效, 因为两个线程几乎同时执行,在判断 if 的时候. 钱还是 1000. 这个时候判断下来肯定可以取钱的.
发现没有, 不知不觉我们好像,,,抢银行了. 别怕. 银行不可能这么蠢的. 在银行中, 我们如果两个人同时取钱, 其中的一个就必须要等待. 等待另一个人取完了钱, 操作完成了, 才可以继续进行. 像这样的操作. 我们被称之为线程同步.
线程同步: 两个线程同时访问一个共享的资源时. 容易因为资源争抢的问题产生数据异常. 此时, 我们可以暂时的把并行的两个线程变成串行的. 这样的话, 访问共享资源就编程了一个一个的同步执行了.
java 如何实现线程同步??
方案一: 在方法上添加 synchronized 关键字

public synchronized void getMoney(){

结果:

时间:1592655192611,第0次取钱,余额是1000.0
时间:1592655192611,第1次取钱,余额是0.0
余额不足!

方案二: 在方法内部使用 synchronized 代码块把同步的内容包裹起来

public void getMoney(){
    synchronized (this) {	//等同于方案一 public修饰的方法
        if(balance <= 0){
            System.out.println("余额不足!");
            return;
        }
        Date d1 = new Date();
        System.out.println("时间:"+d1.getTime()+",第"+count+"次取钱,余额是"+balance);
        count++;    //判断是第几次取钱
        balance-=1000;
        System.out.println("时间:"+d1.getTime()+",第"+count+"次取钱,余额是"+balance);
    }
}

方案三: 使用 Lock 锁

private Lock lock = new ReentrantLock();    //创建锁
    public void getMoney(){
        lock.lock();    //锁上
        if(balance <= 0){
            System.out.println("余额不足!");
            return;
        }
        Date d1 = new Date();
        System.out.println("时间:"+d1.getTime()+",第"+count+"次取钱,余额是"+balance);
        count++;    //判断是第几次取钱
        balance-=1000;
        System.out.println("时间:"+d1.getTime()+",第"+count+"次取钱,余额是"+balance);
        lock.unlock();  //一定要记得解开锁
    }

通过上面的一个案例,我们了解到线程同步的 3 种方法,但是现在有一个问题 synchronized 修饰的是 public 方法,synchronized(this){} 也只能是 public 方法,那么 static 方法怎么办呢?

2.卖票案例


SellTicket.java

public class SellTicket implements Runnable{
    private int tickets = 100;
    @Override
    public void run() {
        while (true){
            if(tickets>0){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+"正在出售第"+tickets+"张票");
                tickets--;
            }
        }
    }
}

SellTicketDemo.java

public class SellTicketDemo {
    public static void main(String[] args) {
        SellTicket st = new SellTicket();
        Thread t1 = new Thread(st,"窗口A");
        Thread t2 = new Thread(st,"窗口B");
        Thread t3 = new Thread(st,"窗口");
        t1.start();
        t2.start();
        t3.start();
    }
}

3.线程同步方法总结

为什么会出现该问题呢?(这也是我们判断多线程程序是否有数据安全问题的标准

  • 是否是多线程环境
  • 是否共享数据
  • 是否有多条语句操作共享数据

如何解决多线程数据安全问题?

  • 基本思想:破坏 3 个环境(主要是破坏最后一个多条语句操作共享数据)

如何实现?

  • 把多条语句操作共享数据的代码给锁起来,让任意时刻只能有一个线程执行即可

方案一:同步代码块

方案二:同步方法

静态同步方法:

方案三:Lock 锁

三种方法的区别?

  • 如果针对对象要加同步锁,就加在方法上
  • 如果针对某一段代码需要加同步锁,那就直接在代码块上加同步锁

synchronize 和 Lock 有什么区别吗?
java.util.concurrent.lock 中的 Lock 框架是锁定的一个抽象,通过对 lock 的 lock()方法和 unlock()方法实现了对锁的显示控制,而 synchronize()则是对锁的隐性控制。

七、死锁

上节课我们讲了线程同步以及 synchronized 怎么来使用,那线程同步给我们带来的好处呢就是可以让两个线程同时访问一个共享资源的时候, 可以一个一个的来,其他的线程在后面排队等待,那线程同步有什么弊端呢? 有, 就是大名鼎鼎的死锁问题。
来我们来看一个死锁的效果

线程 A 从上到下执行, 线程 B 也是从上到下执行。线程 A 调用资源 1 并且一直没有执行完,线程 B 调用资源 2 并且一直没有执行完,线程 A 需要资源 2,线程 B 需要资源 1,导致 AB 线程都在等待对方释放资源, 这俩人就杠上了,谁都不肯释放掉对方需要的资源。程序就会”死”在这里,这个就是死锁。
我们用代码来模拟一下死锁的效果:
DeadLock1

public class DeadLock1 extends Thread{
    @Override
    public void run() {
        synchronized (ShareObject.obj_1){
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("我是线程-"+Thread.currentThread().getName()+",拿到了obj_1");
            synchronized (ShareObject.obj_2){
                System.out.println("我是线程:"+Thread.currentThread().getName()+",拿到了obj_2");
                System.out.println("我解放了。。。");
            }
        }
    }
}

DeadLock2

public class DeadLock2 extends Thread{
    @Override
    public void run() {
        synchronized (ShareObject.obj_2){
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("我是线程:"+Thread.currentThread().getName()+",拿到了obj_2");
            synchronized (ShareObject.obj_1){
                System.out.println("我是线程-"+Thread.currentThread().getName()+",拿到了obj_1");
                System.out.println("我解放了。。。");
            }
        }
    }
}

ShareObject.java

package com.xjt.DeadLock;
public class ShareObject {
    public static Object obj_1 = new Object();
    public static Object obj_2 = new Object();
}

Main.java

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new DeadLock1();
        Thread t2 = new DeadLock2();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("end main thread...");
    }
}

打印效果:

程序就死在这里了,不能向前继续执行,这就是死锁。我们只是举一个例子, 真正开发环境中的死锁不会这么直白的放在这里的, 我们需要反复的读代码,才能找到死锁. 所以, 我们用 synchronized 要格外注意。

八、线程的生命周期

世间万物, 任何东西都有自己的声明周期, 比如, 一个人, 从出生到死亡, 这是一个固定的流程. 没有人可以超脱这个流程, 我们用的线程也是如此. 从启动线程到线程的消亡是有一个明确的声明周期的.
学习生命周期可以帮我们更好的分析和理解线程的机制.

我们创建好一个线程对象, 此时该线程并没有启动执行, 当执行了 start()之后, 该线程并不是直接就执行的, 而是进入就绪状态, 表示, 我准备好了可以开始了。然后就进入到 CPU 调度时间(抢夺 CPU 执行权),当 CPU 调度到当前线程的时候,该线程开始运行, 如果线程产生了大量的 IO 或者访问了 sleep() 或者执行时间过长, CPU 会让这个线程暂停执行, 处于阻塞状态, 然后 CPU 去忙别的事。等 IO 操作完毕了, 该线程继续进入到 CPU 的任务队列(进入就绪状态), 等待 CPU 的下一次运行。当线程最后一行代码执行完毕或者中途执行了 stop(), 该线程结束, 线程消亡。
综上, 记住, 线程不 start()是不会启动的, 线程什么时候运行, 由 CPU 进行任务调度, 我们不能认为的去控制。这也是线程让程序员又爱又恨的地方 ->不可控。如果线程搞不好, 程序可能会出大问题。比如, 莫名其妙死机, 莫名其妙资源占用率过高. 还有一些若隐若现的 BUG 出现。
设置多少线程合适?
根据任务的不同, 我们分配的线程数也是不同的. 但不是越多越好. 因为过多的线程切换也是很消耗 CPU 资源的.
一般情况下, 我们设置线程的数量是 CPU 核心数的两倍。

九、生产者消费者模型

生产牛奶和消费牛奶案例:

等待 wait()和唤醒线程 notify()


Box.java

package com.xjt.milk;
public class Box{
    //第x瓶奶
    private int milk;
    //奶箱状态 是否有奶
    private boolean state = false;
    public Box(int milk, boolean state) {
        this.milk = milk;
        this.state = state;
    }
    public synchronized void put(int num){
        //如果有牛奶就等待
        if (state){
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }else{
            //如果没有牛奶就放
            this.milk = num;
            System.out.println("送奶工:"+Thread.currentThread().getName()+"将第"+this.milk+"瓶牛奶放入奶箱");
            //放牛奶之后修改状态
            state = true;
            //唤醒其他的等待线程
            notifyAll();
        }
    }
    public synchronized void get(){
        //如果没有牛奶就等待
        if (!state){
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }else{
            //如果有牛奶就拿
            System.out.println("用户:"+Thread.currentThread().getName()+"拿到第"+this.milk+"瓶牛奶");
            //拿牛奶之后修改状态
            state = false;
            //唤醒其他的等待线程
            notifyAll();
        }
    }
}

MilkDemo.java

public class MilkDemo{
    public static void main(String[] args) {
        //创建奶箱对象,这是共享数据区域
        Box b = new Box(1,false);
        //创建生产者对象,把奶箱作为构造方法参数传递,因为这个类中要调用放牛奶的操作
        Producer p = new Producer(b);
        //创建消费者对象,把奶箱作为构造方法参数传递,因为这个类中要调用拿牛奶的操作
        Customer c = new Customer(b);
        //创建两个线程对象,分别将生产者消费者作为构造方法参数传递
        Thread t1 = new Thread(p);
        Thread t2 = new Thread(c);
        //启动线程
        t1.start();
        t2.start();
        System.out.println("main thread end...");
    }
}

Customer.java

public class Customer implements Runnable{
    private Box b;
    public Customer(Box b) {
        this.b = b;
    }
    @Override
    public void run() {
        //获取牛奶箱中的牛奶,每次1瓶,死循环不断取
        while (true){
            try {
                Thread.sleep(200);
                this.b.get();      //200ms取一次牛奶
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Producer.java

public class Producer implements Runnable{
    private Box b;
    private int count=1;
    public Producer(Box b) {
        this.b = b;
    }
    @Override
    public void run() {
        //往牛奶箱中放牛奶
//        for (int i=1;i<=5;i++){
//            this.b.put(i);  //一次放5瓶奶
//        }

        //不断的往奶箱中放牛奶
        while (true){
            try {
                Thread.sleep(1000);
                this.b.put(count);      //100ms放一次牛奶
                count++;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

十、线程池

我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题:
如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低
系统的效率,因为频繁创建线程和销毁线程需要时间。
那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?
在 Java 中可以通过线程池来达到这样的效果。今天我们就来详细讲解一下 Java 的线程池。

1.线程池概念

线程池:其实就是一个容纳多个线程的容器,其中的线程可以反复使用,省去了频繁创建/销毁线程对象的操作,
无需反复创建线程而消耗过多资源。
由于线程池中有很多操作都是与优化资源相关的,我们在这里就不多赘述。我们通过一张图来了解线程池的工作原
理:

合理利用线程池能够带来三个好处:

  1. 降低资源消耗。减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。

  2. 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。

  3. 提高线程的可管理性。可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内 存,而把服务器累趴下(每个线程需要大约 1MB 内存,线程开的越多,消耗的内存也就越大,最后死机)。

2.线程池的使用

Java 里面线程池的顶级接口是 java.util.concurrent.Executor ,但是严格意义上讲 Executor 并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是 java.util.concurrent.ExecutorService 。
要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,很有可能配置的线程池不是较优
的,因此在 java.util.concurrent.Executors 线程工厂类里面提供了一些静态工厂,生成一些常用的线程池。官方建议使用 Executors 工程类来创建线程池对象。
Executors 类中有个创建线程池的方法如下:
public static ExecutorService newFixedThreadPool(int nThreads):返回线程池对象。(创建的是有界线程池,也就是池中的线程个数可以指定最大数量)
获取到了一个线程池 ExecutorService 对象,那么怎么使用呢,在这里定义了一个使用线程池对象的方法如下:
public Future<?> submit(Runnable task):获取线程池中的某一个线程对象,并执行 Future 接口:用来记录线程任务执行完毕后产生的结果。
线程池方法介绍:

线程池创建和使用:

代码:

public class Test1 {
    public static void main(String[] args) {
        //1.使用线程池的工厂类Executors里面提供的静态方法news生产一个指定线程数量的线程池
        ExecutorService ec = Executors.newFixedThreadPool(2);
        //创建Runnable实例对象
        MyRunnable r = new MyRunnable();
        //3.调用ExecutorService中的方法submit,传递线程任务(实现类),开启线程,执行run方法
        ec.submit(r);
        ec.submit(r);   //再次调用
        ec.submit(r);   //再次调用
        //4.销毁线程池
        ec.shutdown();  //不建议用,销毁之后线程池中的线程就没了
    }
}
//2.创建一个类实现Runnable接口,重写run方法,设置线程任务
class  MyRunnable implements Runnable{
    @Override
    public void run() {
        System.out.println("我需要一个游泳教练");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("教练来了: " + Thread.currentThread().getName());
        System.out.println("教我游泳,交完后,教练回到了游泳池");
    }
}

打印结果:

十一、lambda 表达式

1.函数式编程思想

在数学中,函数就是有输入量、输出量的一套计算方案,也就是“拿什么东西做什么事情”。相对而言,面向对象过
分强调“必须通过对象的形式来做事情”,而函数式思想则尽量忽略面向对象的复杂语法——强调做什么,而不是以
什么形式做
面向对象的思想:
做一件事情,找一个能解决这个事情的对象,调用对象的方法,完成事情.
函数式编程思想:
只要能获取到结果,谁去做的,怎么做的都不重要,重视的是结果,不重视过程

2.冗余的 Runnable 匿名内部类

传统写法
当需要启动一个线程去完成任务时,通常会通过 java.lang.Runnable 接口来定义任务内容,并使用
java.lang.Thread 类来启动该线程。代码如下:

public static void main(String[] args) {
   // 匿名内部类
   Runnable task = new Runnable() {
       @Override
       public void run() { // 覆盖重写抽象方法
           System.out.println("多线程任务执行!");
       }
   };
   new Thread(task).start();   // 启动线程
}

本着“一切皆对象”的思想,这种做法是无可厚非的:首先创建一个 Runnable 接口的匿名内部类对象来指定任务内
容,再将其交给一个线程来启动。
代码分析
对于 Runnable 的匿名内部类用法,可以分析出几点内容:

  • Thread 类需要 Runnable 接口作为参数,其中的抽象 run 方法是用来指定线程任务内容的核心;
  • 为了指定 run 的方法体,不得不需要 Runnable 接口的实现类;
  • 为了省去定义一个 RunnableImpl 实现类的麻烦,不得不使用匿名内部类;
  • 必须覆盖重写抽象 run 方法,所以方法名称、方法参数、方法返回值不得不再写一遍,且不能写错;
  • 而实际上,似乎只有方法体才是关键所在

编程思想转换
做什么,而不是怎么做
我们真的希望创建一个匿名内部类对象吗?不。我们只是为了做这件事情而不得不创建一个对象。我们真正希望做
的事情是:将 run 方法体内的代码传递给 Thread 类知晓。
传递一段代码——这才是我们真正的目的。而创建对象只是受限于面向对象语法而不得不采取的一种手段方式。
那,有没有更加简单的办法?如果我们将关注点从“怎么做”回归到“做什么”的本质上,就会发现只要能够更好地达
到目的,过程与形式其实并不重要。
生活举例
当我们需要从北京到上海时,可以选择高铁、汽车、骑行或是徒步。我们的真正目的是到达上海,而如何才能到达
上海的形式并不重要,所以我们一直在探索有没有比高铁更好的方式——搭乘飞机。
而现在这种飞机(甚至是飞船)已经诞生:2014 年 3 月 Oracle 所发布的 Java 8(JDK 1.8)中,加入了Lambda 表达
的重量级新特性,为我们打开了新世界的大门。

3.Lambda 写法

借助 Java 8 的全新语法,上述 Runnable 接口的匿名内部类写法可以通过更简单的 Lambda 表达式达到等效:

public static void main(String[] args) {
    new Thread(()> System.out.println("多线程任务执行!")).start(); // 启动线程
}

这段代码和刚才的执行效果是完全一样的,可以在 1.8 或更高的编译级别下通过。从代码的语义中可以看出:我们
启动了一个线程,而线程任务的内容以一种更加简洁的形式被指定。
不再有“不得不创建接口对象”的束缚,不再有“抽象方法覆盖重写”的负担,就是这么简单!
回顾匿名内部类
Lambda 是怎样击败面向对象的?在上例中,核心代码其实只是如下所示的内容:

()> System.out.println("多线程任务执行!")

为了理解 Lambda 的语义,我们需要从传统的代码起步。
使用实现类
要启动一个线程,需要创建一个 Thread 类的对象并调用 start 方法。而为了指定线程执行的内容,需要调用
Thread 类的构造方法:

  • public Thread(Runnable target)

为了获取 Runnable 接口的实现对象,可以为该接口定义一个实现类 RunnableImpl :

public class RunnableImpl implements Runnable {
    @Override
    public void run() {
        System.out.println("多线程任务执行!");
    }
}

然后创建该实现类的对象作为 Thread 类的构造参数:

public class Demo03ThreadInitParam {
    public static void main(String[] args) {
        Runnable task = new RunnableImpl();
        new Thread(task).start();
    }
}

使用匿名内部类
这个 RunnableImpl 类只是为了实现 Runnable 接口而存在的,而且仅被使用了唯一一次,所以使用匿名内部类的
语法即可省去该类的单独定义,即匿名内部类

public class Demo04ThreadNameless {
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("多线程任务执行!");
            }
        }).start();
    }
}

匿名内部类的好处与弊端
一方面,匿名内部类可以帮我们省去实现类的定义;另一方面,匿名内部类的语法——确实太复杂了!
语义分析
仔细分析该代码中的语义, Runnable 接口只有一个 run 方法的定义:
public abstract void run();
即制定了一种做事情的方案(其实就是一个函数):

  • 无参数:不需要任何条件即可执行该方案。
  • 无返回值:该方案不产生任何结果。
  • 代码块(方法体):该方案的具体执行步骤。

同样的语义体现在 Lambda 语法中,要更加简单:

()> System.out.println("多线程任务执行!")
  • 前面的一对小括号即 run 方法的参数(无),代表不需要任何条件;
  • 中间的一个箭头代表将前面的参数传递给后面的代码;
  • 后面的输出语句即业务逻辑代码。

Lambda 标准格式

Lambda 省去面向对象的条条框框,格式由3 个部分组成:

  • 一些参数
  • 一个箭头
  • 一段代码

Lambda 表达式的标准格式为:

(参数类型 参数名称)> { 代码语句 }

格式说明:

  • 小括号内的语法与传统方法参数列表一致:无参数则留空;多个参数则用逗号分隔。
  • -> 是新引入的语法格式,代表指向动作。
  • 大括号内的语法与传统方法体要求基本一致。

4.练习

4.1 无参无返回

给定一个厨子 Cook 接口,内含唯一的抽象方法 makeFood ,且无参数、无返回值。如下:

public interface Cook {
    void makeFood();
}

在下面的代码中,请使用 Lambda 的标准格式调用 invokeCook 方法,打印输出“开始做饭了。。。”字样:

public class NoParamsNoRet {
    public static void main(String[] args) {
        //匿名内部内的写法
        invokeCook(new Cook() {
            @Override
            public void makeFood() {
                System.out.println("开始做饭了。。。");
            }
        });
		//lambda表达式写法
        invokeCook(() -> {
            System.out.println("开始做饭了。。。");
        });
    }
    public static void invokeCook(Cook cook) {
        cook.makeFood();
    }
}

4.2 有参有返回

需求:
使用数组存储多个 Person 对象
对数组中的 Person 对象使用 Arrays 的 sort 方法通过年龄进行升序排序
下面举例演示 java.util.Comparator<T>接口的使用场景代码,其中的抽象方法定义为:
public abstract int compare(T o1, T o2);
当需要对一个对象数组进行排序时, Arrays.sort 方法需要一个 Comparator 接口实例来指定排序的规则。假设有
一个 Person 类,含有 String name 和 int age 两个成员变量:

class Person{
    private String name;
    private int age;
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
}

使用传统方式和 lambda 方式对数组安装 age 排序

public class ArraySort {
    public static void main(String[] args) {
        Person[] arr = {
                new Person("柳岩",38),
                new Person("佟丽娅",35),
                new Person("古力娜扎",19),
        };
        //对数组中的Person对象使用Arrays.sort() 按照age升序
//        Arrays.sort(arr, new Comparator<Person>() {
//            @Override
//            public int compare(Person o1, Person o2) {
//                return o1.getAge()-o2.getAge();
//            }
//        });
        //使用lambda表达式
        Arrays.sort(arr, (Person o1, Person o2) -> {
                return o1.getAge()-o2.getAge();
            }
        );
        //对数组forEach遍历
        for (Person item:arr) {
            System.out.println(item.toString());
        }
    }
}

这种做法在面向对象的思想中,似乎也是“理所当然”的。其中 Comparator 接口的实例(使用了匿名内部类)代表
了“按照年龄从小到大”的排序规则。
代码分析
下面我们来搞清楚上述代码真正要做什么事情。

  • 为了排序, Arrays.sort(数组,排序规则) 方法需要排序规则,即 Comparator 接口的实例,抽象方法 compare 是关键;
  • 为了指定 compare 的方法体,不得不需要 Comparator 接口的实现类;
  • 为了省去定义一个 ComparatorImpl 实现类的麻烦,不得不使用匿名内部类;
  • 必须覆盖重写抽象 compare 方法,所以方法名称、方法参数、方法返回值不得不再写一遍,且不能写错;
  • 实际上,只有参数和方法体才是关键

4.3 有参有返回

题目
给定一个计算器 Calculator 接口,内含抽象方法 calc 可以将两个 int 数字相加得到和值:

public interface Calculator {
    int calc(int a,int b);
}

main 主函数

public class ParamsRet {
    public static void main(String[] args) {
        //1.普通形式调用函数,实现接口Calculator的calc方法
//        invokeCalc(2,3, new Calculator() {
//            @Override
//            public int calc(int a, int b) {
//                return a+b;
//            }
//        });
        //2.使用lambda表达式
        invokeCalc(2,3,(int a, int b) -> {
            return a+b;
        });
    }
    public static void invokeCalc(int a,int b,Calculator cal){
        int result = cal.calc(a,b);
        System.out.println("计算结果是:"+result);
    }
}

5.Lambda 省略格式

可推导即可省略
Lambda 强调的是“做什么”而不是“怎么做”,所以凡是可以根据上下文推导得知的信息,都可以省略。
省略规则
在 Lambda 标准格式的基础上,使用省略写法的规则为:

  1. 小括号内参数的类型可以省略;

  2. 如果小括号内有且仅有一个参,则小括号可以省略;

  3. 如果大括号内有且仅有一个语句,则无论是否有返回值,都可以省略 大括号、return 关键字及语句分号。

注意:第 3 条规则 大括号 return 分号 要省略就全都省略,否则只省略一个两个会报错

例如上例还可以使用 Lambda 的省略写法:

//使用lambda表达式
invokeCalc(2,3,(int a, int b) -> {
    return a+b;
});
//lambda表达式的省略写法
invokeCalc(2,3,(a, b) -> a+b);
//使用lambda表达式
Arrays.sort(arr, (Person o1, Person o2) -> {
    return o1.getAge()-o2.getAge();
}
	);
//lambda表达式的省略写法
Arrays.sort(arr, (o1, o2) -> o1.getAge()-o2.getAge());
invokeCook(() -> {
    System.out.println("开始做饭了。。。");
});
//lambda表达式的省略写法
invokeCook(() -> System.out.println("开始做饭了。。。"));

6.Lambda 的使用前提

Lambda 的语法非常简洁,完全没有面向对象复杂的束缚。但是使用时有几个问题需要特别注意:

  1. 使用 Lambda 必须具有接口,且要求接口中有且仅有一个抽象方法

无论是 JDK 内置的 Runnable 、 Comparator 接口还是自定义的接口,只有当接口中的抽象方法存在且唯一
时,才可以使用 Lambda。

  1. 使用 Lambda 必须具有上下文推断

也就是方法的参数或局部变量类型必须为 Lambda 对应的接口类型,才能使用 Lambda 作为该接口的实例。

备注:有且仅有一个抽象方法的接口,称为“函数式接口”。


文章作者: CoderXiong
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 CoderXiong !
  目录