全栈开发那些事

全栈开发那些事

多线程

6
2024-06-25
多线程

1、线程概述

线程是进程中一个小的执行单位,线程是不能脱离进称独立存在的,一个进程中可以有一个或多个线程。

1.1 进程

激活所有的操作系统都支持进程,当一个程序进入内存运行时,就启动了一个进程,即进程是处于运行过程的程序。每个进程都具有一定的独立功能,操作系统会给每个进程分配独立的内存等资源,即进程是操作系统资源分配、调度和管理的最小单位。

进程有如下三个特性:

  • 独立性:进程是操作系统进行资源分配和调度的一个独立单位,每个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。
  • 动态性:程序只是一个静态指令的集合,而进程是一个正在系统中运行的活动指令的集合。进程中加入了时间的概念,进程具有自己的生命周期和各种不同状态,这些概念在程序中都是不具备的。
  • 并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会相互影响。

==并行是指在同一时刻,有多条指令在多个处理器上同时执行。==

==并发是指在同一时刻只能有一条指令执行,但多个进程的指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。==

对于某些资源来说,在同一时间只能被一个进程占用,这些一次只能被一个进程占用的资源就是临界资源。当多个进程都要访问临界资源时,它们就构成了竞争的互斥关系。

而有时候为了完成某种任务,多个进程在某些位置上需要通过互相发送消息、互相合作、互相等待来协调它们的工作次序,这种直接制约关系,称为同步

1.2 线程

多线程扩展了多进程的概念,使得一个进程可以同时并发处理多个任务,线程也被称为轻量级进程。就像进程在操作系统中的地位一样,线程在进程中也是独立的、并发的执行流。当进程被初始化后,主线程就被创建了,对于Java程序来说,main线程就是主线程,我们可以在该进程中创建多条顺序执行路径,这些独立的执行路径都是线程。

进程中的每个线程可以完成一定的任务,并且是独立的,县城可以拥有自己独立的堆栈、程序计数器和局部变量,但不再拥有系统资源,它与父进程的其他线程共享该进程所拥有的系统资源。

线程的执行也是抢占式的,也就是说,当前运行的线程在任何时候都可能被挂起,以便另一个线程可以运行。CPU可以在不同的进程之间轮换,进程又在不同的线程之间轮换,因此==线程是CPU执行和调度的最小单元。==

总之,一个程序运行后至少有一个进程,一个进程中可以包含多个线程,但至少要包含一个线程。当操作系统创建一个进程时,必须为该进程分配独立的内存空间,并分配大量的相关资源,创建一个线程则简单的多。如果此时有多个任务同时执行的需求,那么选择创建多进程的方式势必耗时费力,创建多个线程则要简单的多。

2、线程的创建和启动

在java中可以通过java.lang.Thread类实现多线程。所有的线程对象都必须是Thread类或其子类的对象。每个线程的作用是完成一定的任务,实际上就是执行一段代码,称之为线程执行体。java使用run方法来封装这段代码,即run方法的方法体就是线程执行体。

2.1 继承Thread类

在java中,线程是Thread类的对象,如果要创建和启动自己的线程,那么就可以直接继承Thread类。步骤如下:

  • 定义继承Thread类的子类,并重写该类的run方法。
  • 创建Thread子类的实例对象,一个实例对象就是一个线程对象。
  • 调用线程对象的start方法来启动线程,如果没有start方法启动,那么这个线程对象和普通java对象没有区别。

案例需求:在主线程中打印5-1的数字,另外启动两个线程打印1-5的数组,并实现这三个线程同时运行。

public class MyThread extends Thread {

    //重写 Thread类的run方法
    @Override
    public void run() {
        for (int i = 0; i <=5; i++) {
            System.out.println(getName()+"线程:"+i);
        }
    }

    public static void main(String[] args) {
        MyThread my1 = new MyThread();
        my1.start();
        MyThread my2 = new MyThread();
        my2.start();
        for(int i=5;i>=1;i--){
            System.out.println(Thread.currentThread().getName()+"线程:"+i);
        }
    }
}

image-20220927214831967

getName()方法是Thread类的实例方法,该方法返回当前线程对象的名称,除了main线程的名称,其他线程的名称默认依次为Thread-0、Thread-1等 。

可以通过setName(String name)方法设置线程的名称。

启动线程用start()方法,而不是run()方法。

2.2 实现Runnable接口

java有单继承的限制,所以除了可以直接继承Thread类,java还提供了实现java.lang.Runnabke接口的方式来创建自己的线程类。步骤如下:

  • 定义Runnable接口的实现类,并重写该接口的run()方法。
  • 创建Runnable接口实现类的对象。
  • 创建Thread类的对象,并将Runnable接口实现类的对象作为target。该Thread类的对象才是真正的线程对象。当JVM调用线程对象的run()方法时,如果target不为空,那么就会调用target的run()方法。
  • 调用线程对象的start()方法启动线程。

案例需求:在主线程中打印5-1的数字,另外启动两个线程打印1-5的数组,并实现这三个线程同时运行。

public class MyRunnable implements Runnable {

    //实现Runnable接口的run方法
    @Override
    public void run() {
        for (int i = 1; i <=5; i++) {
            System.out.println(Thread.currentThread().getName()+"线程:"+i);
        }
    }
}
public class MyRunnableTest {
    public static void main(String[] args) {
        MyRunnable my = new MyRunnable();
        new Thread(my).start();
        new Thread(my).start();

        for(int i=5;i>=1;i--){
            System.out.println(Thread.currentThread().getName()+"线程:"+i);
        }
    }
}

image-20220927215418224

2.3 继承Thread类和实现Runnable接口的区别

实现Runnable接口的方式,无疑比继承Thread类的方式更加灵活,避免了单继承的局限性。另外在处理有共享资源的情况时,实现Runnable接口的方式更容易实现资源的共享。

案例需求:使用多线程模拟三个售票窗口,共售出100张票。

案例分析:三个线程的任务是一样的,因此只需要定义一个线程类编写任务体,然后创建三个线程对象即可。

(1)使用继承Thread类的方式实现。

卖票线程类代码:

public class SellTicketThread extends Thread {
    private int tickets=100; //票数

    @Override
    public void run() {
        while(true){
            if(tickets<=0){
                System.out.println("票已经卖完");
                break;
            }
            System.out.println(Thread.currentThread().getName()+"卖了一张票,目前票数:"+(--tickets));
        }
    }
}

主线程测试类代码:

public class SellTicketTest1 {
    public static void main(String[] args) {
        SellTicketThread s1 = new SellTicketThread();
        SellTicketThread s2 = new SellTicketThread();
        SellTicketThread s3 = new SellTicketThread();

        s1.start();
        s2.start();
        s3.start();
    }
}

image-20220927215853594

运行上述代码发现三个售票窗口都独享100张票。原因是创建了三个SellTickThread对象,而每次对象都将tickets的值初始化为100,因此每个线程对象就相当于独享100张票。

(2)使用实现Runnable接口的方式实现

卖票线程类代码:

public class SellTicketRunnable implements Runnable {
    private int tickets=100;
    @Override
    public void run() {
        while(true){
            if(tickets<=0){
                System.out.println("票已经卖完");
                break;
            }
            System.out.println(Thread.currentThread().getName()+"卖了一张票,目前票数:"+(--tickets));
        }
    }
}

主线程测试类:

public class SellTicketTest2 {
    public static void main(String[] args) {
        SellTicketRunnable st = new SellTicketRunnable();
        new Thread(st).start();
        new Thread(st).start();
        new Thread(st).start();
    }
}

image-20220927220602898

这里发现,它很好地实现了3个售票窗口共享100张票。因为我们只创建了一个SellTickRunnable对象,三个线程共享同一个SellTickRunnable对象。

案例总结:

  • 实现Runnable接口的方式,有效地避免了单继承的局限性。
  • 实现Runnable接口的方式,更适合处理有共享资源的情况。

3、线程的生命周期

在JDK1.5以前,一个完整的线程的生命周期通常要经历五种状态,这是从操作系统层面来描述的:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(dead)。CPU需要在多个线程之间转换,于是线程状态会多次在运行、阻塞、就绪之间转换。

4、Thread类的方法

  • Thread():创建新的Thread对象,线程名称是默认的。
  • Thread(String threadname):创建线程并手动指定线程名称。
  • Thread(Runnable target):指定创建线程的目标对象,它必须实现Runnable接口,线程名称是默认的。
  • Thread(Runnable target,String name):指定创建线程的目标对象,并手动指定线程名称。
  • public void run():子类必须重写run()方法以编写线程体。
  • public void start():启动线程。

案例需求:用实现Runnable接口的方式启动一个线程打印1-100的偶数,用继承Thread类的方式启动一个线程打印1-100的奇数,两个线程同时运行。

public class TestThreadMethod {
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i=2;i<=100;i+=2){
                    System.out.println("偶数线程:"+i);
                }
            }
        }).start();

        new Thread(){
            @Override
            public void run() {
                for(int i=1;i<=100;i+=2){
                    System.out.println("奇数线程:"+i);
                }
            }
        }.start();
    }
}

image-20220927221553471

4.1 获取和设置线程信息

  • public static Thread currentThread():静态方法,总是返回当前执行的线程对象。
  • public final boolean isAlive():测试线程是否处于活动状态,如果线程已经启动且尚未终止,则为活动状态。
  • public final String getName():Thread类的实例方法,该方法返回当前线程对象的名称。
  • public final void setName(String name):设置该线程名称。
  • public final int getPriority():返回线程优先级。
  • public final void setPriority(int newPriority):改变线程的优先级。

案例需求:使用多线程模拟两个售票窗口,共同售出100张票。两个线程分别命名为普通窗口和紧急窗口。

public class TestThreadMethod1 {
    public static void main(String[] args) {
        Runnable runnable = new Runnable(){
            private int tickets=100;  //票数
            @Override
            public void run() {
                while(true){
                    if(tickets<=0){
                        System.out.println("票已经售完");
                        break;
                    }
                    System.out.println(Thread.currentThread().getName()+"卖了一张票,目前票数:"+(--tickets));
                }
            }
        };

        Thread t1 = new Thread(runnable, "普通窗口");
        Thread t2 = new Thread(runnable, "紧急窗口");
        //设置线程的优先级
        t1.setPriority(Thread.MIN_PRIORITY);
        t2.setPriority(Thread.MAX_PRIORITY);

        System.out.println("普通窗口的优先级:"+t1.getPriority());
        System.out.println("紧急窗口的优先级:"+t2.getPriority());
        System.out.println("主线程的优先级:"+Thread.currentThread().getPriority());

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

image-20220927222032308

4.2 案例:倒计时

通过Thread类的sleep方法实现新年倒计时效果。

public class SleepTest {
    public static void main(String[] args) {

        for(int i=10;i>=0;i--){
            System.out.println(i);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("国庆快乐!!!");
    }
}

image-20220927222138683

4.3 案例:线程让步

yield方法只是让当前线程暂停一下,让系统的线程调度器重新调度一次,希望优先级与当前线程相同或更高的其他线程能够获得执行机会,但是这种情况不能保证执行。完全有可能的情况是,当某个线程调用yield方法暂停后,线程调度器又将其调度出来重新执行。

public class YieldTest {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {

            @Override
            public void run() {
                for(int i=1;i<=5;i++){
                    System.out.println(Thread.currentThread().getName()+":"+i);
                    Thread.yield();
                }
            }
        };

        Thread t1 = new Thread(runnable, "高");
        t1.setPriority(Thread.MAX_PRIORITY);

        Thread t2 = new Thread(runnable, "低");
        t2.setPriority(Thread.MIN_PRIORITY);

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

image-20220927222349815

4.4 案例:龟兔赛跑

==当在某个线程的线程体中调用另一个线程的join方法时,当前线程将被阻塞,知道join进来的线程执行完(join()不限时加塞),或者阻塞一段时间后(join(millis)限时加塞),它才能继续执行。==

案例需求:编写龟兔赛跑多线程程序。假设赛跑长度为30米,兔子的速度为10米每秒,兔子没跑完10米后休眠的时间为10秒;乌龟的速度为1米每秒,乌龟没跑完10米后休眠的时间为1秒。最后要等兔子和乌龟的线程结束,主线程(裁判)才能公布最后的结果。

跑步者线程代码如下:

public class Racer extends Thread {
    private String name;    //运动员姓名
    private long runTime;   //没米需要时间,单位毫秒
    private long restTime;//每10米的休息时间,单位毫秒
    private long distance;//全程距离,单位米
    private long time;//跑完全程的总时间
    private boolean finished;//是否跑完全程

    public Racer( String name,long distance, long runTime, long restTime) {
        super();
        this.name = name;
        this.runTime = runTime;
        this.restTime = restTime;
        this.distance = distance;
    }

    @Override
    public void run() {
        long sum=0;
        long start=System.currentTimeMillis();
        while(sum<distance){
            System.out.println(name+"正在努力奔跑...");
            try {
                Thread.sleep(runTime);//每米距离,该运动员需要的时间
            } catch (InterruptedException e) {
                System.out.println(name+"出现意外");
                e.printStackTrace();
            }
            sum++;
            try {
                if(sum%10==0&&sum<distance){
                    //每10米休息一下
                    System.out.println(name+"已经跑了"+sum+"米正在休息......");
                    Thread.sleep(restTime);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        long end=System.currentTimeMillis();
        time=end-start;
        System.out.println(name+"跑了"+sum+"米,已到达终点,共用时"+(double)time/1000.0+"秒");
        finished=true;
    }
    public long getTime(){
        return time;
    }
    public boolean isFinished(){
        return finished;
    }
}

主裁判测试类代码:

public class RacerTest {
    public static void main(String[] args) {
        Racer rabbit = new Racer("兔子", 30, 100, 10000);
        Racer turtoise = new Racer("乌龟", 30, 1000, 1000);

        rabbit.start();
        turtoise.start();

        try {
            rabbit.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        try {
            turtoise.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //因为要兔子和乌龟都跑完,才能公布结果
        System.out.println("比赛结束");
        if(rabbit.isFinished()&&turtoise.isFinished()){
            if(rabbit.getTime()<turtoise.getTime()){
                System.out.println("兔子赢");
            }else if(rabbit.getTime()>turtoise.getTime()){
                System.out.println("乌龟赢");
            }else{
                System.out.println("平局");
            }
        }else if(rabbit.isFinished()||turtoise.isFinished()){
            System.out.println(rabbit.isFinished()?"兔子赢":"乌龟赢");
        }else{
            System.out.println("乌龟和兔子都没有到达终点比赛取消");
        }
    }
}

image-20220928203043163

4.5 案例:守护线程

**有一种线程,它是在后台运行的,它的任务是为其他线程提供服务,这种线程称为守护线程。守护线程有个特点,就是如果所有的非守护线程都死亡,那么守护线程会自动死亡。**JVM的垃圾回收线程就是典型的守护线程。

案例需求:为主线程启动一个守护线程,守护线程没1毫秒打印一句话“我是MyDaemon,codeleader”,主线程打印1-10 的数字,查看运行效果。

image-20220928203413534

5、线程同步

5.1 线程安全问题

当多线程操作共享资源时,共享资源出现错乱就是线程安全问题线程安全问题都是由共享变量引起的,共享变量一般都是某个类的静态变量,或者因为多个线程使用了同一个对象的实例变量,方法的局部变量是不可能称为共享变量的。

如果每个线程对共享变量只有读操作,而无(修改)写操作,那么一般来说,这个共享变量是线程安全的。如果有多个线程同时执行写操作,那么一般都需要考虑线程安全问题。

案例需求:使用多线程模拟三个窗口售票,共同售出10张票。

资源类(票)的代码:

public class Ticket {
    private int total=10; //票数

    public void sale(){
        if(total>0){
            --total;
            System.out.println(Thread.currentThread().getName()+"卖出一张票,剩余:"+total);
        }else{
            throw new RuntimeException("没有票了");
        }
    }

    public int getTotal(){
        return total;
    }
}

卖票线程代码:

public class SaleThread extends Thread {
    private Ticket ticket;  //票资源对象

    public SaleThread(String name,Ticket ticket){
        super(name);
        this.ticket=ticket;
    }

    @Override
    public void run() {
        while(true){
            try {
                //加入休眠时间使得问题暴露的更明显
                Thread.sleep(100);
                ticket.sale();
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
                break;
            }
        }
    }
}

测试类

public class SaleTicketDemo1 {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();

        SaleThread t1 = new SaleThread("窗口一", ticket);
        SaleThread t2 = new SaleThread("窗口二", ticket);
        SaleThread t3 = new SaleThread("窗口三", ticket);

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

多线程卖票:(线程安全问题)

情况一:出现票重复售出的情况

image-20220928204529417

情况二:多卖:3个窗口卖出的票数超过了10张

image-20220928204413317

5.2 同步代码块

==为了解决线程安全为题,Java提供了synchronized关键字。该关键字的作用就是对多条操作共享数据的语句加锁,被锁住的语句代码只能让一个线程执行完后,其他线程才能进入,否则在这个线程执行过程中,其他线程不可以参与执行,这个锁称为同步锁。==

Java语句中的同步锁是通过一个对象当监视者来实现的,因此我们把同步锁又称为对象监视器。也就是当我们使用synchronized关键字时,一定要有一个锁对象配合工作。synchronized关键字的使用形式有两种:同步代码块同步方法

同步代码块的语法格式如下:

synchronized(同步监视器对象){
  //...
}

上述代码的含义是线程在开始执行同步代码块之前,必须先获得同步监视器的锁定(占有),换句话所如果没有获得对同步监视器的锁定,那么就不能进入同步代码块的执行,线程就会进入阻塞状态,知道对方释放了对同步监视器对象的锁定。

Java的同步锁可以是任意引用类型的对象。同步锁看起来锁的是代码,其实本质上锁的是对象,即在同步锁对象中有锁标记,能够明确知道现在是哪个线程在占用锁。任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行结束后,该线程自然会释放对同步监视器对象的锁定。所以,==我们必须保证竞争共享资源的这几个线程,选的是同一个同步监视器对象,否则无法实现同步效果。==

下面使用同步代码块解决售票案例的线程安全问题:

资源类(Ticket类)的代码修复:

public class Ticket {
    private int total=10;

    public void sale(){
        synchronized (this){
            if(total>0){
                --total;
                System.out.println(Thread.currentThread().getName()+"卖出一张票,剩余:"+total);
            }else{
                throw new RuntimeException("没有票了");
            }
        }
    }

    public int getTotal() {
        return total;
    }
}

售票线程类示例代码:

public class SaleThread extends Thread {
    private Ticket ticket;

    public SaleThread(String name,Ticket ticket){
        super(name);
        this.ticket=ticket;
    }

    @Override
    public void run() {
        while(true){
            try {
                Thread.sleep(1000);
                ticket.sale();
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
                break;
            }
        }
    }
}

测试类代码:

public class SaleTicketDemo2 {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();

        SaleThread t1 = new SaleThread("窗口一", ticket);
        SaleThread t2 = new SaleThread("窗口二", ticket);
        SaleThread t3 = new SaleThread("窗口三", ticket);

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

image-20220928205616452

可以看出,线程安全问题被完美解决了。

Ticket类中使用了同步代码块,并且选择了this对象作为同步监视器对象,这里的this对象代表Ticket对象本身,窗口一、窗口二、窗口三线程使用的Ticket对象是同一个对象,即同步监视器对象是同一个对象,所以没问题。

==注意:不是所有情况都可以使用this对象作为同步监视器对象的。因为当多个线程执行synchronized同步代码块时,this对象代表的不是同一个对象,那么它将失去同步监视器的作用,即不能解决线程安全问题。==

5.3 同步方法

同步方法就是使用synchronized关键字来修饰的某个方法,该方法称为同步方法。==对于同步方法而言,不需要显示指定同步监视器,因为静态方法的同步监视器就是当前类的Class对象,而非静态方法的同步监视器对象调用的是当前方法的this对象。==

下面使用同步方法来解决售票案例的线程安全问题。

资源类(Ticket)类的示例代码修复。

public class Ticket {
    private int total=10;

    public synchronized void sale(){
        if(total>0){
            --total;
            System.out.println(Thread.currentThread().getName()+"卖出一张票,剩余:"+total);
        }else{
            throw new RuntimeException("没有票了");
        }
    }

    public int getTotal() {
        return total;
    }
}

售票线程类代码

public class SaleThread  extends Thread{
    private Ticket ticket;

    public SaleThread(String name,Ticket ticket){
        super(name);
        this.ticket=ticket;
    }

    @Override
    public void run() {
        while(true){
            try {
                Thread.sleep(1000);
                ticket.sale();
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
                break;
            }
        }
    }
}

测试类代码

public class SaleTicketDemo3 {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();

        SaleThread t1 = new SaleThread("窗口一", ticket);
        SaleThread t2 = new SaleThread("窗口二", ticket);
        SaleThread t3 = new SaleThread("窗口三", ticket);

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

image-20220928211423778

可以看出,线程安全问题也被完美的解决了。

特别提示:不要对线程安全类的所有方法都加同步,只对会影响竞争资源(共享资源)的方法进行同步即可。而且也要注意非静态同步方法的默认同步的监视器对象对于竞争资源的多个线程来说是否是同一个对象,如果不是同一个对象是起不到监视作用的。

5.4 释放锁与否的操作

(1)释放锁的操作:

  • 当前线程的同步方法、同步代码块执行结束。
  • 当前线程在同步代码块、同步方法中遇到break和return终止了该同步代码块或同步方法的继续执行。
  • 当前线程在同步代码块、同步方法中出现了未处理的错误或异常,导致当前线程异常结束。
  • 当前线程在同步代码块、同步方法中执行了锁对象的wait()等方法,当前线程被挂起,并释放锁。

(2)不会释放锁的操作:

  • 当线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法暂停当前线程的执行。
  • 当线程执行同步代码块时,其他线程调用了该线程的suspend()等方法将该线程挂起,该线程不会释放锁。

5.5 死锁

当不同的线程分别锁住对象需要的同步监视器对象不释放,都在等待对方先放弃时,就会形成死锁。一旦出现死锁,整个程序既不会发生异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。

5.6 案例:线程交替打印数字

实现两个线程交替打印1-100的整数,一个线程打印奇数,另一个线程打印偶数,要求输出结果有序,即奇数线程打印一个数字之后,交给偶数线程打印一个偶数,再让奇数线程继续打印,以此类推。

分析:因为两个线程需要交替打印1-100的整数,所以声明一个打印数字线程类(PrintNumber),并且用一个num变量记录当前需要打印的数字,两个线程交替修改。同一个时刻修改num值和打印num值的代码只能让一个线程运行,所以必须放到同步块或同步方法中。实现交替打印的效果,就是一个线程打印完就等待,这样另一个线程就可以打印。另外,要记得唤醒等待的线程。

打印数字线程类示例代码;

public class PrintNumber implements Runnable{
    private int num =1;

    @Override
    public void run() {
        while(true){
            synchronized (this){
                try {
                    this.notify();
                    if(num<=100){
                        System.out.println(Thread.currentThread().getName()+":"+num);
                        num++;
                        this.wait();
                    }else{
                        break;
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

测试类:

public class PrintNumberTest {
    public static void main(String[] args) {

        PrintNumber p = new PrintNumber();

        new Thread(p).start();
        new Thread(p).start();
    }
}

image-20220928212751199

6、单例设计模式

单例设计模式是软件开发中最常用的设计模式之一。它是某个类在整个系统中只能有一个实例对象可被获取和使用的代码模式。例如,代表JVM运行环境的Runtime类。

根据创建单例类对象的时机,单例设计模式可分为饿汉式懒汉式两种。

6.1 饿汉式

所谓饿汉式,是指在类初始化时,直接创建对象。饿汉式单例设计模式的优点是不存在线程安全问题,因为Java的类加载和初始化的机制绝对可以保证线程安全;缺点是不管是否需要该实例对象,都会创建,这会使得类初始化时间及对象占用内存时间加长。

饿汉式单例设计模式实现方式有如下三种:

(1)直接实例化

直接实例化是JDK1.5之前实现枚举类的方式,如下所示。

public class Singleton1{
 public static final Singleton1 INSTANCE=new Singleton1();
 private Singleton(){
 }
}

(2)新式枚举式

JDK1.5之后有更简单的方式实现枚举了。

public enum Singleton2{
	INSTANCE
}

(3)饿汉式静态代码块

当创建单例类的实例对象,需要做的初始化操作比较复杂时,可以选择在静态代码块中做相关的初始化操作,然后创建该实例对象。

第一步:在src下先建立一个single.properties文件,内容如下:

image-20220928213614570

第二步:编写单例类

public class Singleton3 {

    public static final Singleton3 INSTANCE;
    private String info;

    static {
        try {
            Properties pro = new Properties();
            pro.load(Singleton3.class.getClassLoader().getResourceAsStream("single.properties"));
            INSTANCE=new Singleton3(pro.getProperty("info"));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public Singleton3(String info) {
        this.info=info;
    }

    @Override
    public String toString() {
        return "Singleton3{" +
                "info='" + info + '\'' +
                '}';
    }
}

测试类:

public class TestSingle3 {
    public static void main(String[] args) {
        System.out.println(Singleton3.INSTANCE);
    }
}

image-20220928213721991

上述代码把single.properties文件放到了src下,当项目工程编译时,会把src下的资源文件同.java文件一起编译到类路径下,即和字节码文件放在一起。当需要加载single.properties文件时,可以让类加载器帮忙加载。

6.2 懒汉式

所谓懒汉式,是指延迟创建对象,直到用户获取这个对象时再创建

懒汉式单路设计模式的优点是不用时不创建,用的时候再创建,减少了对象占用内存的时间;缺点是可能存在线程安全问题

懒汉式单例设计模式的实现方式主要有以下两种:

  • 一种是在get单例对象的方法中创建单例对象,该实现方式可能存在线程安全问题。
  • 另一种是用静态内部类形式存储单例类对象,该实现方式没有线程安全问题。

6.2.1 在get单例对象的方法中创建单例对象

为了说明问题,我们先写一个有线程安全问题的版本。

单例类示例代码:

public class Singleton4 {
    private static Singleton4 instance;

    //私有构造器
    private Singleton4(){

    }

    public static Singleton4 getInstance(){
        if(instance==null){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            instance=new Singleton4();
        }
        return instance;
    }
}

测试类:

public class SingletonTest {
    private static Singleton4 instance1;
    private static Singleton4 instance2;

    public static void main(String[] args) {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                instance1=Singleton4.getInstance();
            }
        };
        t1.start();

        Thread t2 = new Thread() {
            @Override
            public void run() {
                instance2 = Singleton4.getInstance();
            }
        };
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(instance1);
        System.out.println(instance2);
        System.out.println(instance1==instance2);
    }
}

image-20220928214413927

上面的代码在运行时出现了线程安全问题,这就会出现两个单例类实例对象的情况。

我们需要改进一下单例类的代码,来避免线程安全问题。

改进版的单例类示例代码:

public class Singleton5 {
    private static Singleton5 instance;
    //私有构造器
    private Singleton5(){

    }

    //双重检查锁
    public static Singleton5 getInstance(){
        if(instance==null){
            synchronized (Singleton5.class){//加锁
                if(instance==null){//再次判空
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    instance=new Singleton5();
                }
            }
        }
        return instance;
    }
}

测试类:

public class SingletonTest1 {
    private static Singleton5 instance1;
    private static Singleton5 instance2;

    public static void main(String[] args) {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                instance1=Singleton5.getInstance();
            }
        };
        t1.start();

        Thread t2 = new Thread() {
            @Override
            public void run() {
                instance2 = Singleton5.getInstance();
            }
        };
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(instance1);
        System.out.println(instance2);
        System.out.println(instance1==instance2);
    }
}

image-20220928214732951

用多线程获取单例类的实例对象,发现线程安全问题已经解决了。

6.2.2 静态内部类形式存储单例类对象

静态内部类形式的单例类示例代码:

public class Singleton6 {
    //私有构造器
    private Singleton6(){

    }

    //静态内部类
    private static class Inner{
        private static final Singleton6 INSTANCE=new Singleton6();
    }

    public static Singleton6 getInstance(){
        return Inner.INSTANCE;
    }
}

测试类;

public class SingletonTest2 {
    private static Singleton6 instance1;
    private static Singleton6 instance2;

    public static void main(String[] args) {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                instance1=Singleton6.getInstance();
            }
        };
        t1.start();

        Thread t2 = new Thread() {
            @Override
            public void run() {
                instance2 = Singleton6.getInstance();
            }
        };
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(instance1);
        System.out.println(instance2);
        System.out.println(instance1==instance2);
    }
}

image-20220928214944617

因为静态内部类Inner的初始化并不是随着外部类的初始化而初始化的,而是在调用getInstance方法时用到Inner类时才初始化的。