Java多线程快速入门学习

进程与线程的区别

进程

进程是正在运行的程序,是系统进行资源分配和调用的独立单位,没一个进程都有它自己的内存空间和系统资源。

线程

线程是进程中的单个顺序控制流,是一条执行路径。单线程程序和多线程程序的区分,就在于进程中是否有多条执行路径。如果你还是不知道执行路径是什么的话,那就看看这些例子:

  • 你打开了一个【记事本】程序,然后打开它的【页面设置】界面,这时候你再点击输入框,先输入点东西,你发现一直在提醒你先把【页面设置】关闭再尝试输入。这就是只有一条执行路径的应用程序。

  • 扫雷游戏,当你点击了一下其中的单元格,计时器就开始计时,这就是两个不同的线程所控制的。

快速开始

多线程的创建步骤

  1. 继承Thread类
  2. 重写run方法,run方法为线程体
  3. 调用线程的start()方法
public class MyThread01 extends Thread{
    @Override
    public void run() {
        // 这里的值尽量设置大一点,否则执行太快,很难看到线程交错执行的效果
        for (int i = 0; i < 100; i++) {
            System.out.println(getName()+ " " + i);
        }
    }

    public static void main(String[] args) {
        MyThread01 mt = new MyThread01();
        MyThread01 mt2 = new MyThread01();
        mt.start();
        mt2.start();
    }
}

执行结果:
在这里插入图片描述

我们可以看到两个线程同时执行的效果,为了方便查看,我加入线程的名称再进行运行一次
在这里插入图片描述

我们可以看到两个线程在交错执行,没有出现等待谁执行完成后再执行的情况。这就是多线程最直观的案例。

修改线程的名称

上面打印出来线程的名称,其实是使用了线程的getName()方法,通过这个方法可以返回线程的名称。在没有设置线程的名称时,线程会默认以Thread-作为前缀。这部分可通过源码进行解读,我们在新建线程类的时候都是使用了线程的无参构造方法,所以我们查看线程的无参构造方法:

public Thread() {
        this.init((ThreadGroup)null, (Runnable)null, "Thread-" + nextThreadNum(), 0L);
}

在这里我们可以直接看到了Thread-的字样,我们就可以猜测这部分的代码实现了。name作为Thread类中一个全局变量,在使用无参构造方法时,会调用init()方法进行初始化赋值。我们先进入init()方法一探究竟:我们发现init()方法再调用了自身的重载方法,最终出现了关键的this.name设置name属性。

在这里插入图片描述

到了这里,我们很好奇那个nextThreadNum()做了些什么,他是怎么实现排号的呢?它先是定义了一个全局的静态线程计数变量,再将nextThreadNum()方法实现同步锁机制,以防出现在多线程的情况获取了相同的计数值。

private static int threadInitNumber;
private static synchronized int nextThreadNum() {
    return threadInitNumber++;
}

知道了原始的线程名称的由来了,那我们再探究一下怎么去实现修改线程的名称。有getName()方法那自然少不了setName()方法,

public final synchronized void setName(String var1) {
    this.checkAccess();
    if (var1 == null) {
        throw new NullPointerException("name cannot be null");
    } else {
        this.name = var1;
        if (this.threadStatus != 0) {
            this.setNativeName(var1);
        }

    }
}

我们先不用管前面的方法做了什么判断,看到赋值是通过this.name = var1就明白了。

那我们接下来用上面的例子来实现改名操作

public static void main(String[] args) {
    MyThread01 mt = new MyThread01();
    MyThread01 mt2 = new MyThread01();
    mt.setName("飞机");
    System.out.println(mt.getName());
}

在这里插入图片描述

那我们可能想通过构造方法来实现名称设置,我们再来查看一下源码:源码中正好有这么一个构造方法,在这里我也将无参构造方法一并拷过来,这么一对比,就恍然大悟了,它就是将你传进来的值替代了"Thread-" + nextThreadNum()这段代码。

public Thread() {
        this.init((ThreadGroup)null, (Runnable)null, "Thread-" + nextThreadNum(), 0L);
}

public Thread(String var1) {
    this.init((ThreadGroup)null, (Runnable)null, var1, 0L);
}

那我们接下来实践一下看看是否真的能够实现构造方法修改名称。

public class MyThread01 extends Thread{
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(getName()+ " " + i);
        }
    }

    // 添加了有参和无参两个构造方法
    public MyThread01() {
    }

    public MyThread01(String s) {
        super(s);
    }

    public static void main(String[] args) {
        MyThread01 mt = new MyThread01();
        MyThread01 mt2 = new MyThread01();
        mt.setName("飞机");
        System.out.println(mt.getName());
        MyThread01 mt3 = new MyThread01("高铁");
        System.out.println(mt3.getName());
//        mt.start();
//        mt2.start();
    }
}

在这里插入图片描述

修改线程名称的方式就上述所说的通过setName()有参构造方法两种形式。

main方法执行所在的线程

我们经过前面的探索可以知道了getName()可以获取线程的名称,那我们的main方法是属于哪个线程中的呢?main方法不是线程中方法,没办法通过Thread对象.getName()来直接获得线程的名称。

这时候就要借助于Thread类中的一个静态方法:currentThread(),该方法返回一个线程。

public static native Thread currentThread();

这个方法被native所修饰,属于C++编写的代码。我们没办法直接查看该方法的源码,在这里我们就简单演示一下用该方法来获取main方法所在的线程的名称。

public static void main(String[] args) {
    System.out.println(Thread.currentThread().getName());
}

最后输出的结果为 main 线程
在这里插入图片描述

修改线程的优先级

Java的线程执行机制属于抢占式调度模型,所以会出现执行的时间片不相同的情况。比如上述的例子,执行每个线程的执行时间不固定,可以执行很长,也可以执行很短。而这个抢占式是基于线程的优先级来进行划分的。

我们可以通过getPriority()来获取线程的优先级

MyThread01 mt = new MyThread01();
MyThread01 mt2 = new MyThread01();
mt.setName("飞机");
System.out.println(mt.getName());
MyThread01 mt3 = new MyThread01("高铁");
System.out.println(mt3.getName());
System.out.println("mt3的线程优先级:" + mt3.getPriority());

在这里插入图片描述

运行结果为5,这就是线程默认的优先级。我们回到源码中去探究一下,这线程的优先级的设值范围是多少。

public static final int MIN_PRIORITY = 1;
public static final int NORM_PRIORITY = 5;
public static final int MAX_PRIORITY = 10;

通过Thread类中定义的常量我们就可以知道,线程中的优先级范围是[1, 10]。接下来我们来看看设置线程优先级会有什么限制

public final void setPriority(int var1) {
    this.checkAccess();
    if (var1 <= 10 && var1 >= 1) {
        ThreadGroup var2;
        if ((var2 = this.getThreadGroup()) != null) {
            if (var1 > var2.getMaxPriority()) {
                var1 = var2.getMaxPriority();
            }

            this.setPriority0(this.priority = var1);
        }

    } else {
        throw new IllegalArgumentException();
    }
}

先抛开其他的复杂的逻辑不谈,我们大概知道,setPriority()在取值为[1, 10]的范围是不会有什么问题的。接下来那我们就做个测试,看看线程优先级对线程执行顺序的影响。

        mt.setPriority(10);	// 飞机
        mt2.setPriority(1); // 火车
        mt3.setPriority(5); // 高铁

我预期出现的结果会是,先把【飞机】线程执行完成,再执行【高铁】线程,最后再执行【火车】线程。

但其实结果是这样:
在这里插入图片描述

飞机的还没执行完,就开始执行火车和高铁的线程了。这属实是意料之外的。

这让我重新再理解一次抢占式调度模型的概念:线程优先级高仅仅表示线程获取的CPU时间片的几率高,但是要在次数比较多,或者多次运行的时候才能看到一开始我预期的效果。

线程控制

sleep(): 当前线程停留/暂停执行指定的毫秒数,这个方法也是被native修饰的。

我们都知道前面的案例执行的顺序都是不固定的,谁也不知道哪个线程在什么时候执行。假如这个时候,我们突然有这么一个需求:我需要上面的3个线程间隔去执行,这个时候该怎么办?我们只能通过sleep()方法,让每个线程执行完一次就暂停一下。

同样是上面的案例,在run方法中,设置输出一次就等待100毫秒,实现了3个线程的交替执行。

public class MyThread01 extends Thread{
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(getName()+ " " + i);
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public MyThread01() {
    }

    public MyThread01(String s) {
        super(s);
    }

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getPriority());
        MyThread01 mt = new MyThread01();
        MyThread01 mt2 = new MyThread01();
        mt.setName("飞机");
        mt2.setName("火车");
        MyThread01 mt3 = new MyThread01("高铁");
        mt.start();
        mt2.start();
        mt3.start();

    }
}

在这里插入图片描述

join(): 等待该线程死亡再执行下面的线程。

还记得我们前面在线程优先级的时候原本的预期是,飞机执行完后,再执行高铁,最后到火车。但是我们发现调整线程的优先级并不能绝对保证线程是执行顺序。这个时候,我们可以试试使用join()方法。我们在【飞机】与【高铁】之间添加了join()方法,在【高铁】与【火车】添加了join()方法。

public class MyThread01 extends Thread{
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(getName()+ " " + i);
        }
    }

    public MyThread01() {
    }

    public MyThread01(String s) {
        super(s);
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(Thread.currentThread().getPriority());
        MyThread01 mt = new MyThread01();
        MyThread01 mt2 = new MyThread01();
        mt.setName("飞机");
        mt2.setName("火车");
        MyThread01 mt3 = new MyThread01("高铁");
        
        mt.start();
        mt.join();
        mt3.start();
        mt3.join();
        mt2.start();
        

    }
}

这个时候我们发现,一切都有序了

在这里插入图片描述
在这里插入图片描述

这就是join()的作用,join()也可以用于接收多线程中的返回值。

setDaemon():设置该线程为主线程的守护线程。主线程消亡的时候,守护线程也很快死亡。

这个时候,就得换一个案例了:刘备、张飞、关羽桃园3结义,不求同年同月同日生,但求同年同月同日死。那如果刘备挂了,张飞和关羽也得英勇赴死。

package ThreadTest1;

public class MyThread02 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(getName() + " " + i);
        }
    }

    public MyThread02() {
    }

    public MyThread02(String s) {
        super(s);
    }

    public static void main(String[] args) {
        MyThread02 guanyu = new MyThread02("关羽");
        MyThread02 zhangfei = new MyThread02("张飞");

        Thread.currentThread().setName("刘备");
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
        guanyu.setDaemon(true);
        zhangfei.setDaemon(true);
        guanyu.start();
        zhangfei.start();
    }
}

运行结果:

在这里插入图片描述

根据代码,原本的【张飞】和【关羽】线程应该执行到99才结束的,但是由于设置为守护线程,在主线程【刘备】执行到9的时候,他两就不活了,直接挂了。

线程的生命周期

在这里插入图片描述

使用Runnable接口来创建线程
  1. 定义一个类MyRunnable实现Runnable接口
  2. 在MyRunnale类中重写run()方法
  3. 创建MyRunnable类的对象
  4. 创建Thread类的对象,把MyRunnable对象作为构造方法的参数
  5. 启动线程
// 1. 实现Runnable接口
public class MyRunnable implements Runnable{
    // 2. 重写run方法
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }

    public static void main(String[] args) {
        // 3. 创建MyRunnable对象
        MyRunnable mr = new MyRunnable();
        // 4.创建线程对象
        Thread t1 = new Thread(mr);
        Thread t2 = new Thread(mr);
		// 5. 启动线程
        t1.start();
        t2.start();
    }
}

认真看上面这段代码的同学,可能就已近发现了,为什么上面的getName()不能直接调用,反而前面要用Thread.currentThread().getName()的形式?那我们来看看Runnable接口中到底有些什么东西。

@FunctionalInterface
public interface Runnable {
    void run();
}

@FunctionalInterface 注解的含义是【函数式接口】,用来标注该接口中有且仅有一个抽象方法。如果一个接口中包含不止一个抽象方法,那么不能使用@FunctionalInterface,编译会报错。

Runnable接口中真的除了一个run方法什么都没,那我们怎么可能可以获取到当前线程的getName()方法呢,只能通过Thread.currentThead()来获取当前线程。所以涉及线程的其他操作,就只能新建Thread对象,将Runnable对象作为参数传入,再进行其他的关于线程的操作。

为什么要用Runnable接口

这个时候,有些同学就要说了,那Runnable这么麻烦,直接用Thread线程类不香吗?确实,使用Thread类来创建线程确实很省事。不过,要考虑Java单继承的特性,你继承了其他类的时候,又恰好需要用到多线程,这时候你总没办法继承Thread类了吧。除了这个,使用Runnable实现的线程,能够把线程和程序代码、数据有效分离,较好地体现了面向对象编程。

综上所述,相比于继承Thread类,实现Runnable接口的好处:

  • 避免了Java单继承的局限性
  • 适合多个相同程序的代码去处理同一个资源的情况,把线程和程序的代码、数据有效分离,较好地体现了面向对象编程。

线程安全/线程同步

线程同步这个问题,是多线程必须解决和搞明白的,也是面试的重点。我们都知道多线程是异步执行的,而且我们都不知道执行顺序。那可能有同学就不太明白,为什么需要用到线程同步呢?接下来我们来实战案例讲解。我建议认真看一遍下面的代码,有助于你理解线程同步的概念。

案例:

我们要实现一个卖票的流程,一共有100张票,当卖完的时候就不进行卖票操作了。

我们先编写好了一个线程代码

public class SellTicket implements Runnable{
    int ticketNum = 100;
    @Override
    public void run() {
        while (true) {	// 这里的死循环模拟票售空时还有人来问的情况
            if (ticketNum > 0) {
                System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
                ticketNum--;
            }
        }
    }
}

再编写一个测试类

public class Main {
    public static void main(String[] args) {
        SellTicket sellTicket = new SellTicket();
        Thread t1 = new Thread(sellTicket, "窗口1");
        t1.start();	
    }
}

目前模拟的效果是,只有一个窗口在售票,执行也是完全无差错的。

在这里插入图片描述

那这个时候,老板看了看手中的投诉信,再看了看外面排队买票的人的队伍,决定要多开两个窗口售票。那就是新增多两个线程

public class Main {
    public static void main(String[] args) {
        SellTicket sellTicket = new SellTicket();
        Thread t1 = new Thread(sellTicket, "窗口1");
        Thread t2 = new Thread(sellTicket, "窗口2");
        Thread t3 = new Thread(sellTicket, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

我们先粗略看一眼结果,确实是实现了3个窗口卖票了。效率大大提高
在这里插入图片描述

那这个程序也不符合现实逻辑,在卖票的时候,不应该办理手续什么的之类的吗?怎么可能一上来就卖出了。OK,那我们用个sleep()方法,模拟售票所花的时间,这总没问题了吧

public class SellTicket implements Runnable{
    int ticketNum = 100;
    @Override
    public void run() {
        while (true) {
            if (ticketNum > 0) {
                try {
                    Thread.sleep(100);	// 添加了sleep()方法模拟卖票的办理手续时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
                ticketNum--;
            }
        }
    }
}

在执行之前,我们感觉这个程序很眼熟,很像上面的那个【交替执行】的程序,那这里会出现什么问题呢?执行一下

在这里插入图片描述

为什么会出现重复售出同一张票,而且出现了断码?

我们来结合代码分析一下,假设票号100的票重复售出了3次

public class SellTicket implements Runnable{
    int ticketNum = 100;
    @Override
    public void run() {
        while (true) {
            if (ticketNum > 0) {
                try {
                    // 1. 窗口1执行到这里休眠
                    // 2. 窗口2争夺CPU执行时间,执行到这里也休眠了
                    // 3. 窗口3争夺CPU执行时间,执行到这里也休眠了
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 假设3个线程按顺序唤醒
                // 4. 窗口1已经将票号100的票售出
                System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
                // 5. 这时候窗口2被唤醒了,票号还没自减,而窗口2再将票售出了
                // 6. 很巧,窗口3也是在票号自减之前被唤醒了,又将售出
                
                // 7. 在窗口1自减后进到下一层的时候又碰到了slee(),在窗口1被唤醒售票之前,窗口2和窗口3都自减了一遍,那等窗口1醒来的时候,就成了97票号了。中间断层了99和98
                ticketNum--;
            }
        }
    }
}

这售出的负数号的票也是和上面第7步一样的原因。

在这里插入图片描述

售票案例数据安全问题的解决

我们来分析一下,为什么会出现这种问题?这也是多线程出现数据安全问题的原因

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

当上面的3个条件都满足时,程序肯定会出现数据安全问题。我们回来分析我们的程序:

  • 3个售票窗口 -> 多线程环境
  • 总共100张票 -> 共享数据
  • 售票和票数自减 -> 多条语句操作共享数据

三个全中,所以我们的程序就会出现数据安全问题。

如何解决多线程的安全问题呢?

基本思想:让上面的三个条件不能同时满足。那我们的程序,就是要3个窗口售卖100张票,这两个是一定要留下来的,那只能去破坏【多条语句操作共享数据】这个条件。

怎么去实现呢?

把多条语句操作共享数据的代码起来,让任意时刻只能有一个线程访问,这就可以保证操作的数据安全。而这个【锁】的操作,Java中提供了同步代码块的方式来解决。

synchronized(任意对象) {
	多条语句操作共享数据
}

这任意对象就是随便的一个对象都行,没什么特别用处,就是用于标记为同一个锁即可,所以你不能写成 synchronized(new Object()),这样的话,每次访问都不是同一个锁,无法保证共享数据的同步执行。

我们用synchronized同步代码块来改写程序

public class SellTicket implements Runnable{
    int ticketNum = 100;
    Object object = new Object();	// 声明了一个Object对象用于标记同步锁
    @Override
    public void run() {
        while (true) {
            synchronized (object) { // 将多条语句操作共享数据的代码包括起来
                if (ticketNum > 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
                    ticketNum--;
                }
            }
        }
    }
}

这时候一运行,就把数据安全问题解决了,注意窗口之间票号衔接就知道了运行是不是正确的了。

在这里插入图片描述

那我们再回到代码中,讲解一下同步代码块的执行顺序:假设当前票号为100,三个线程按顺序执行

public class SellTicket implements Runnable{
    int ticketNum = 100;
    Object object = new Object();
    @Override
    public void run() {
        while (true) {
            // 1.窗口1来到了同步代码块
            // 3.窗口2来到了同步代码块,发现被锁住了,哪怕窗口1是锁门睡觉的,我也等
            // 4.窗口3也一样,等锁释放
            synchronized (object) {
                // 8.锁释放,窗口2进来了……
                // 9.重复类似窗口1的操作
                if (ticketNum > 0) {
                    try {
                        // 2.窗口1睡着了
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 5.窗口1睡醒了,卖票
                    System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
                    // 6.卖出了一张,票号减1
                    ticketNum--;
                }
            }
            // 7.窗口1释放锁
        }
    }
}

那同步代码是不是就真的完全只有好处,没有弊端呢?

肯定不是的,我们的这个案例,使用同步代码块之后,执行的时间比异步执行的时候慢很多很多

  • 好处:解决了多线程的数据安全问题
  • 弊端:当线程很多的时候,每个线程都会去判断同步上的锁,非常耗费资源,无形中会降低程序的运行效率。

同步方法的使用

还是上面的售票案例,如果我们有这样的一个需求,当 x % 2 == 0 时,这张票是一等座,贵一点,否则就是二等座。

public class SellTicket implements Runnable{
    int ticketNum = 100;
    Object object = new Object();
    @Override
    public void run() {
        while (true) {
            if (ticketNum % 2 == 0) {
                synchronized (object) {
                    if (ticketNum > 0) {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
                        ticketNum--;
                    }
                }
            } else {
                synchronized (object) {
                    if (ticketNum > 0) {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
                        ticketNum--;
                    }
                }
            }

        }
    }
}

执行没什么问题

在这里插入图片描述

这个时候,你感觉这样重复的代码很多啊,维护不便,以后被人看到成千古罪人,得把【二等座】的逻辑抽成一个方法。于是把代码修改成这个样子

package ThreadDemo;

public class SellTicket implements Runnable{
    int ticketNum = 100;
    Object object = new Object();
    @Override
    public void run() {
        while (true) {
            if (ticketNum % 2 == 0) {
                synchronized (object) {
                    if (ticketNum > 0) {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
                        ticketNum--;
                    }
                }
            } else {
                secondClassSeat();
            }

        }
    }
    public void secondClassSeat() {
        synchronized (object) {
            if (ticketNum > 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
                ticketNum--;
            }
        }
    }
}

运行测试也通过。

如果之前有了解过synchronized关键字的同学这个时候就要说了,那把synchronized写到方法上不就好了嘛,还管这么多。

**格式:**修饰符 synchronized 返回值类型 方法名(方法参数) {}

说干就干,于是将synchronized提到了方法上

    public synchronized void secondClassSeat() {
//        synchronized (object) {
            if (ticketNum > 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
                ticketNum--;
            }
//        }
    }

没任何报错,看来语法是正确的,来运行一下

在这里插入图片描述

这怎么回事啊,怎么就锁不住了?

我们在上面讲synchronized的时候有提到过,线程的锁对象必须是同一个对象才能锁住共享资源,用同步代码块的时候,我们可以通过synchronized(同一对象)的方式来指定同一个锁对象。那我们的方法上的synchronize关键字又不能指定对象是吧,那同步方法的锁对象是什么呢?这里就不卖管子了,直接说:this 。就是他的对象本身。

那这个程序怎么改,就简单了,讲上面的同步代码块的对象改成了synchronized(this)即可。

public void run() {
        while (true) {
            if (ticketNum % 2 == 0) {
                synchronized (this) {		// <------------- 改成this
                    if (ticketNum > 0) {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
                        ticketNum--;
                    }
                }
            } else {
                secondClassSeat();
            }

        }
    }

运行结果,又回到了正常

在这里插入图片描述

这时候又想把刚才的【二等座】方法改成静态的了

格式:修饰符 static synchronized 返回值类型 方法名(方法参数) {}

package ThreadDemo;

public class SellTicket implements Runnable{
    static int ticketNum = 100;			// <---------- static
    Object object = new Object();
    @Override
    public void run() {
        while (true) {
            if (ticketNum % 2 == 0) {
                synchronized (this) {
                    if (ticketNum > 0) {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
                        ticketNum--;
                    }
                }
            } else {
                secondClassSeat();
            }

        }
    }
    public static synchronized void secondClassSeat() {		// <---- static
//        synchronized (object) {
            if (ticketNum > 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
                ticketNum--;
            }
//        }
    }
}

再运行一遍,结果怎么又出问题了!
在这里插入图片描述

会不会还是由于锁对象不同出现的数据安全问题?确实是这样的,同步静态方法是类的方法,他的锁对象是类而不是对象。所以锁对象应该是:类名.class

package ThreadDemo;

public class SellTicket implements Runnable{
    static int ticketNum = 100;
    Object object = new Object();
    @Override
    public void run() {
        while (true) {
            if (ticketNum % 2 == 0) {
                synchronized (SellTicket.class) {		// <-------- 锁对象修改为SellTicket.class
                    if (ticketNum > 0) {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
                        ticketNum--;
                    }
                }
            } else {
                secondClassSeat();
            }

        }
    }
    public static synchronized void secondClassSeat() {
//        synchronized (object) {
            if (ticketNum > 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
                ticketNum--;
            }
//        }
    }
}

再次运行,就又回到正常了

在这里插入图片描述

总结同步方法的两个重要知识点
  • 同步方法的锁对象是this
  • 同步静态方法的锁对象是类名.class

线程安全的类

我们最常听说的线程安全的类就是StringBuffer、Vector、Hashtable这3个,先说说这3个类的作用

  • StringBuffer:
    • 线程安全,可变的字符序列
    • 从JDK 5开始,被StringBuilder替代。通常应该使用StringBuilder类,因为他支持所有相同的操作,因为它不执行同步,所以性能更快
  • Vector
    • Vector类实现了可拓展的对象数组
    • 从Java2平台v1.2开始,该类改进了List接口,使其成为Java Collections Framework的成员。与新的集合平台不同,Vector被同步。如果不需要线程安全的实现,建议使用ArrayList替代Vector
  • Hashtable
    • 该类实现了一个哈希表,它将键映射到值。任何非null对象都可以用作键或者值
    • 从Java2平台v1.2开始,该类改进了Map接口,使其成为Java Collections Framework的成员。与新的集合平台不同,Hashtable被同步。如果不需要线程安全的实现,建议使用HashMap替代Hashtable

我们很好奇,线程安全的类,到底是怎么实现线程安全的?也是通过同步锁来做的吗?

下面这是一段StringBuffer中的代码片段,除了构造方法以外,几乎所有的涉及共享数据操作的方法都加上了同步锁。

在这里插入图片描述

而StringBuilder中的代码,一个同步锁都没有

在这里插入图片描述

Hashtable和Vector也是同样的操作,所以我们明白了,线程安全的类,就是将涉及共享数据的操作方法添加同步锁来实现的。

不过,在多线程的环境下,StringBuffer可能我们会常用,那个Vector和Hashtable,我们是真没见过在实际项目中排上用场的。我们一般使用线程同步的ArrayList,都是通过Collections这个工具类中的synchronizedList()方法来获取一个同步的List集合。

List<Integer> integers = Collections.synchronizedList(new ArrayList<Integer>());

同样的可以有synchronizedMap()synchronizedSet()

我们又很好奇,这个synchronizedList()到底做了什么能实现同步。

一路寻根,它最终的实现类在SynchronizedList中,截取一段代码片段,我们发现它并没有使用同步方法,而是使用了同步代码块的方式

在这里插入图片描述

通过这次学习,我们知道线程安全,都是通过同步方法、同步代码块来实现的,并没有其他神奇的操作。

补充一下:基于性能的考虑,线程安全的HashMap,我们不考虑通过synchronizedMap()方法来返回,而是使用ConcurrentHashMap类,这个类实现同步的方式,是通过实现可重入锁,将锁的粒度继续细分,达到高效的性能。这个后面讲到锁的时候再细说。

Lock锁

虽然我们可以理解同步代码块和同步方法的锁对象问题,但是我们并没有直接看到在哪里加上了锁,在哪里释放了锁,为了更清晰的表达如何加锁和释放锁,JDK5以后提供了一个新的锁对象Lock。

Lock实现提供比使用synchronized方法和语句可以获得更广泛的锁定操作

Lock中提供了获得锁和释放锁的方法

  • void lock(): 获取锁
  • void unlock(): 释放锁

Lock是一个接口,不能直接被实例化,这里采用它的实现类ReentrantLock来实例化

ReentrantLock的构造方法

  • ReentrantLock(): 创建一个ReentrantLock的实例

我们回到刚才的程序来使用锁保证数据安全

public class SellTicket implements Runnable{
    int ticketNum = 100;
    Lock lock = new ReentrantLock();	// <-------- 申明锁
    @Override
    public void run() {
        while (true) {
            try {
                lock.lock();			// <------- 锁定
                if (ticketNum > 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
                    ticketNum--;
                }
            } finally {
                /* 注意:释放锁的操作一定要放到finally中!否则因为当前线程
                 *      运行错误,无法释放锁,导致其他线程无法获取资源,导致死锁
                 */
                lock.unlock();			// <----- 解锁
            }
        }
    }
}

实现的效果和使用synchronized关键字也是一样的

在这里插入图片描述

生产者和消费者模型

在现实生产中,多线程更常用于协助工作的场景。生产者生产资源,放到暂存区中,消费者消耗资源。通过学习掌握这个常用的多线程模型,帮助我们进一步理解多线程。

为了体现生产和消费过程的等待和唤醒,Java提供了几个方法供我们使用,这几个方法在Object类中。注意这3个方法是Object类的,不是Thread类的!(我当年笔试的时候就忘了这个,答错了)

Object类中的等待和唤醒方法:

方法名说明
void wait()导致当前线程等待,直到另一个线程调用该对象的notify()或notifyAll()方法
void notify()唤醒正在等待对象监视器(监视器就是锁)的单个线程
void notifyAll()唤醒正在等待对象监视器的所有线程

Object.wait() 与 Thread.sleep() 的异同

  • 两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • 两者都可以暂停线程的执行。
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。

notify() 与 notifyAll()

当你调用notify时,只有一个等待线程会被唤醒而且它不能保证哪个线程会被唤醒,这取决于线程调度器。

notifyAll() 会将所有等待当前锁的线程唤醒,共同竞争锁资源

接下来我们设计一个案例:你预定了30天的牛奶,送奶工人每天会将一瓶牛奶送到你门口的奶箱。当牛奶送达后,你可以去拿走牛奶去喝。

我们很容易得知,这里的送奶工人其实就是 生产者,奶箱就是 暂存区,而你就是 消费者。生产者在中可以调用暂存区的存入方法,消费者则是调用暂存区中的取出方法。根据上面所述的,这个程序就很容易被设计出来。

StagingArea 【暂存区】

public class StagingArea {
    public void put(int num) {
        System.out.println("存入第" + num + "天的牛奶");
    }

    public void get(int num) {
        System.out.println("取出第" + num + "天的牛奶");
    }
}

Producer 【生产者】

public class Producer implements Runnable{
    StagingArea stagingArea;

    public Producer(StagingArea stagingArea) {
        this.stagingArea = stagingArea;
    }
    @Override
    public void run() {
        for (int i = 1; i <= 30; i++) {
            stagingArea.put(i);
        }
    }
}

Consumer 【消费者】

public class Consumer implements Runnable{
    private StagingArea stagingArea;

    public Consumer(StagingArea stagingArea) {
        this.stagingArea = stagingArea;
    }
    @Override
    public void run() {
        for (int i = 1; i <= 30; i++) {
            stagingArea.get(i);
        }
    }
}

Main 【主函数】

public class Main {
    public static void main(String[] args) {
        StagingArea stagingArea = new StagingArea();
        Producer producer = new Producer(stagingArea);
        Consumer consumer = new Consumer(stagingArea);
        Thread c = new Thread(consumer);
        Thread p = new Thread(producer);

        c.start();
        p.start();
    }
}

看看运行结果

在这里插入图片描述

由于多线程的无序性,出现了奇怪的现象,我这牛奶还没存入,怎么取出来的?这肯定不符合现实的逻辑,我们的消费者得等待生产者将牛奶放入暂存区,才可以取出来。那我们一步一步慢慢探索

我们先在StagingArea中的get方法中加入wait()方法,让消费者等待生产者提供牛奶

public void get(int num) {
    // 等待生产者提供牛奶
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("取出第" + num + "天的牛奶");
}

一运行,发生报错

在这里插入图片描述

IllegalMonitorStateException 官网给出的描述翻译过来是:抛出该异常表明某一线程已经试图等待对象的监视器,或者试图通知其他正在等待对象的监视器,然而本身没有指定的监视器的线程。再次提醒一遍,监视器就是锁,就是说你的线程没有锁,所以不能执行这个方法。

那简单好办,加个synchronized关键字即可

public synchronized void get(int num) {
    // 等待生产者提供牛奶
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("取出第" + num + "天的牛奶");
}

运行一下,是没任何报错,不过这线程怎么被阻塞掉了,就没运行过一次,程序也不会结束,一直在等

在这里插入图片描述

原因也很简单,那是你一直在【wait】,根本就没唤醒过,那我们来唤醒一下,为了方便起见,这里就直接调用了【notifyAll】了

   public synchronized void put(int num) {
        System.out.println("存入第" + num + "天的牛奶");
        notifyAll();
    }

在这里插入图片描述

我们会发现,这怎么都放了30天了,牛奶才拿3天的?因为取牛奶的线程会被阻塞,所以执行效率是一定会比放牛奶的慢,放牛奶的可能执行了N次唤醒,取牛奶的线程才执行完一次。下一次又阻塞了,又得等放牛奶的操作来唤醒取牛奶的操作。所以导致了最后牛奶都放完30天了,取牛奶还在慢吞吞。

因此,我们需要让放牛奶的操作等待取牛奶的操作。这简单,不就在放牛奶的操作里面加一个wait等一下取牛奶的操作,然后取牛奶的操作唤醒放牛奶的操作。so easy! 妈妈再也不用担心我的多线程了!

public class StagingArea {
    public synchronized void put(int num) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("存入第" + num + "天的牛奶");
        notifyAll();
    }

    public synchronized void get(int num) {
        // 等待生产者提供牛奶
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("取出第" + num + "天的牛奶");
        notifyAll();
    }
}

运行看看

在这里插入图片描述

空白输出,但是程序没有停止。这是两个线程都阻塞住了,经典的环路等待,一开始暂存区没有牛奶,等待放牛奶,但是这个放牛奶的一开始就在等待取牛奶的去唤醒他。这怎么行!我们得让送牛奶的那个知道,一开始是没有牛奶的!我们得加个标注,有牛奶就取,没牛奶就等放。

public class StagingArea {

    // 标注有无牛奶
    private boolean state = false;

    public synchronized void put(int num) {
        // 如果有牛奶
        if (state) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 没有牛奶
        System.out.println("存入第" + num + "天的牛奶");
        state = true;
        notifyAll();
    }

    public synchronized void get(int num) {
        // 等待生产者提供牛奶
        if (!state) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("取出第" + num + "天的牛奶");
        state = false;
        notifyAll();
    }
}

最终程序得以完美运行

在这里插入图片描述

总结

写了这么多,主要是带大家熟悉了多线程的创建方法,同步的使用,锁的使用,以及Object类关于线程的方法的使用。根据具体的使用场景和可能遇到的问题,详细地给大家介绍了多线程的使用场景和问题解决。希望大家通过这篇文章可以快速掌握多线程的基本使用,下一步进阶学习线程池的操作。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值