(十五)Java关于多线程编程(详解)

本文介绍了Java多线程编程的基础,包括线程的创建、对象锁、volatile关键字、线程同步以及生产者消费者问题。通过示例展示了如何使用synchronized和wait/notify实现线程间的协作,还探讨了线程安全的单例工厂模式。
摘要由CSDN通过智能技术生成

Java 多线程编程

前言

现代操作系统,如 Window 、Linux 、Unix 等,都是多任务系统。所谓多任务系统是指,“同时”能执行多个任务。(这里并不是真正意义上的同时执行)

几个关键的基本概念(这里涉及《操作系统》的基础知识)

关于多线程编程的基础知识

Java 线程编程

线程编程语法示例

Java 提供了以一个 Thread 类,通过继承这个类,可以实现线程编程。下面是一个简单的线程编程的例子:

public class MyFirstThread extends Thread{
    public MyFirstThread() {
        System.out.println("开始创建线程……");
        //创建线程
        this.start();
        System.out.println("线程已创建完毕!");
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(i);
        }
    }
}

简单书写一个测试类:

public class Test {
    public static void main(String[] args) {
        new MyFirstThread();
    }
}

执行结果如下:

类 MyFirstThread的构造方法中的语句:this.start();是用来创建线程的。这个方法名称让很多人误认为是启动线程的。在《操作系统》基础中有,线程创建后将进入“就绪态”,和创建它的主线程一起竞争CPU,而不是立刻执行。

从执行效果看,当执行完 this.start();语句后,主线程立刻执行了后面的 System.out.println("线程已创建完毕!");语句,才会出现执行效果图的结果。

还有第二种创建线程的方法:

public class MySecondThread implements Runnable {
    public MySecondThread() {
        System.out.println("开始创建线程……");
        //创建线程
        new Thread(this).start();
        System.out.println("线程已创建完毕!");
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(i);
        }
    }
}

Runnable 接口只规定了 public void run()方法。

创建线程的方法是:new Thread(this).start();

运行结果与上述一致。

两个相关线程

下面设计两个相关线程:

public class TwoRelationThread implements Runnable{
    private static int num;
    private String threadName;

    public TwoRelationThread(String threadName) {
        this.threadName = threadName;
        new Thread(this).start();
    }

    @Override
    public void run() {
        int n;
        for (int i = 0; i < 1000; i++) {
            n = num;
            n = n + 1;
            num = n;
            System.out.println(threadName + ":" + num);
        }
    }
}

给出测试类函数:

public class Test {
    public static void main(String[] args) {
        TwoRelationThread A = new TwoRelationThread("A");
        TwoRelationThread B = new TwoRelationThread("B");
    }
}

结果如下:

并且,我们多次与运行这个程序可以发现,每次其执行顺序都不同。

对象锁

Java 中的对象锁是一种机制,用于实现线程之间的同步和互斥。当一个线程需要访问一个对象的同步代码块时,它必须先获得该对象的锁,从而避免多个线程同时访问该对象的同步代码块。

在 Java 中,对象锁通常使用 synchronized 关键字来实现。synchronized 关键字可以用于修饰方法或代码块,被修饰的方法或代码块在同一时间只能被一个线程执行,其他线程需要等待。

当一个线程进入 synchronized 代码块时,它会尝试获取对象的锁。如果该锁已经被其他线程占用,则该线程会进入阻塞状态,直到获取到锁为止。当该线程执行完 synchronized 代码块后,会释放对象的锁,从而允许其他线程进入 synchronized 代码块执行。

需要注意的是,对象锁是基于对象的,每个对象都有一个锁。因此,当多个线程需要访问不同的对象的同步代码块时,它们之间不会产生互斥。另外,对象锁只能保证同一个线程访问同一个对象的同步代码块时的互斥,无法保证不同线程访问不同对象的同步代码块时的互斥。如果需要实现全局的同步和互斥,可以使用类锁。

除了 synchronized 关键字外,Java 还提供了 Lock 接口和它的实现类 ReentrantLock 来实现对象锁。相比 synchronized 关键字,Lock 接口提供了更多的功能,如可重入锁、公平锁和超时锁等。但是,使用 Lock 接口需要手动控制锁的获取和释放,需要更加谨慎地处理锁的使用,否则可能会出现死锁或其他并发问题。

线程的互斥

​ 如果我们将循环体内的语句当成“临界资源”,当线程 A 进入临界资源后,我们希望线程 A 能充分完成所有循环体里的语句,也就是 num 的计算和输出的执行不要被打断。当轮到线程 B 执行时,应该把线程B阻隔在临界资源的外面,知道线程 A 执行完循环体里的语句后,线程 B 才有可能执行。
为了达到这个目的,我们需要给临界资源“加锁”!

对上述代码进行改进:

public class TwoRelationThread implements Runnable{
    private static int num;
    private static final Object lock = new Object();//定义一个静态常量对象,将它实例化,当成对象锁
    private String threadName;

    public TwoRelationThread(String threadName) {
        this.threadName = threadName;
        new Thread(this).start();
    }

    @Override
    public void run() {
        int n;
        for (int i = 0; i < 1000; i++) {
            synchronized (lock){
                n = num;
                n = n + 30;
                n = n + 1;
                n = n - 30;
                num = n;
                System.out.println(threadName + ":" + num);
            }
        }
    }
}

看这两行代码:

private static final Object lock = new Object();

synchronized (lock){}

synchronized 是 Java 关键字 ,synchronized 大意是“同步”,在这里所起的作用就是“门”和“锁”。门就是其后的一对大括号:这对大括号就是临界资源的起止边界。锁就是 synchronized 后面的 “()” 中的对象。被这个锁拦截的是:不同的线程

当线程 A 进入 synchronized (lock){} 之前,会检查 lock 是否被“锁上”( JVM 内部完成);如果没有锁上,则,先对 lock“上锁”,然后进入“同步块”执行其中的语句。当其它线程(如线程B)也来到锁前,同样会检查锁的状态。现在锁的状态应该是锁上的,因此,线程B被阻塞,不会执行同步块中的语句。如果还有更过线程都来到锁前,都会因为lock的状态是锁上的,而依次进入阻塞态。这样,线程A总是可以顺利执行完同步块中的所有语句。当线程A离开同步块前将锁“打开",并唤醒所有在该锁上阻塞的线程,让它们进入就绪态,等待调度执行。
所以,我们可以预见对于 num 的输出不会再出现混乱的情况。所有计算和输出语句都保证不被打扰的完全执行。

volatile 关键字

在 Java 中,volatile 是一种关键字,用于声明一个变量是易变的(volatile variable),即在程序执行过程中,该变量的值可能被意外地改变,而与程序的控制流无关。

与 C/C++ 中的 volatile 关键字类似,Java 中的 volatile 关键字的作用也是告诉 JVM,该变量的值可能被其他线程修改,因此每次访问该变量时都需要重新从主内存中读取。同时,当一个线程修改了该变量的值后,它也会立即将修改后的值刷新到主内存中,以便其他线程可以看到最新的值。

Java 中的 volatile 变量通常用于在多线程环境中保证变量的可见性和禁止指令重排序。例如,在一个多线程环境中,如果一个线程修改了一个共享变量的值,但是其他线程没有及时看到最新的值,那么就可能会导致程序出现不可预期的结果。如果将该变量声明为 volatile,则可以保证其他线程可以及时看到最新的值,从而避免这种情况。

需要注意的是,虽然 volatile 变量可以保证可见性和禁止指令重排序,但它并不能保证线程安全。如果多个线程同时对一个 volatile 变量进行读写操作,仍然需要使用同步机制来保证线程安全。

线程的同步

​ 这是典型的“生产者-—消费者”问题,其本质是两个线程的同步。其实,生产者-―消费者问题,就是如何让两个线程“交替"执行的问题。这个问题的解决相对比较复杂,要用到由 Object 类提供的 wait() 方法和 notify() 方法。

​ wait() 方法的本质是让执行这个方法的线程进入阻塞态,而 notify() 方法则相反,会唤醒处在阻塞态的相关线程。
在前面讲述进程状态变迁时,讲述过进程 / 线程从运行态变迁到阻塞态,会进入阻塞态多个阻塞队列中的一个队列里。为了能唤醒 进程 / 线程,系统需要知道究竟唤醒哪个队列中的进程/线程。

这是在说:线程在阻塞时,必须指明阻塞队列。而具体操作就是,线程在阻塞时,必须指定一个“锁”,然后进入这个锁的阻塞队列。所以, wait() 方法必须在同步块中调用,并且必须提供锁对象。再者,进程/线程的唤醒操作必须是由其它进程/线程执行的。那么,唤醒者也必须知道那个锁对象。

wait() 和 notify()

wait() 和 notify() 是 Object 类中定义的两个方法,它们通常用于实现线程之间的协作和同步。下面分别介绍它们的作用:

wait() 方法可以使当前线程进入等待状态,并释放对象的锁。当一个线程调用了某个对象的 wait() 方法后,它就会释放该对象的锁,并进入等待状态,直到其他线程调用该对象的 notify() 或 notifyAll() 方法来唤醒它。在等待期间,该线程会暂停执行,并交出 CPU 时间片,进入阻塞状态,直到被唤醒。

notify() 方法用于唤醒其他等待中的线程。当一个线程调用某个对象的 notify() 方法时,它会通知该对象上等待的某个线程,使其从等待状态中恢复到可运行状态。被唤醒的线程会重新尝试获取该对象的锁,一旦获取成功,就可以继续执行。如果有多个线程在等待该对象,则只有其中一个线程会被唤醒。

需要注意的是,wait() 和 notify() 方法必须在同步块中调用,即必须先获取对象的锁,才能调用这两个方法。否则会抛出 IllegalMonitorStateException 异常。另外,wait() 和 notify() 方法也必须在同一对象上调用,即等待和唤醒的线程必须使用同一个对象进行同步和协作。

synchronized

synchronized 修饰方法

​ synchronized 关键字可以用于实现线程之间的同步和互斥。synchronized 关键字可以用于修饰方法或代码块,被修饰的方法或代码块在同一时间只能被一个线程执行,其他线程需要等待。

​ 使用 synchronized 关键字实现线程同步的方式称为互斥锁(mutex)。synchronized 关键字可以保证在同一时间只有一个线程能够执行被 synchronized 修饰的方法或代码块,从而避免了多个线程同时访问共享资源的问题。当一个线程进入 synchronized 代码块时,它会尝试获取对象的锁,如果该锁已经被其他线程占用,则该线程会进入阻塞状态,直到获取到锁为止。

synchronized 关键字的作用范围有两种:

  • 修饰方法:当一个方法被 synchronized 修饰时,该方法在同一时间只能被一个线程执行。如果多个线程同时调用该方法,则它们会依次获得对象的锁,进入阻塞状态。
  • 修饰代码块:当一个代码块被 synchronized 修饰时,该代码块在同一时间只能被一个线程执行。与修饰方法不同的是,synchronized 代码块是以对象为锁的,即需要指定一个对象作为锁,只有获得该对象的锁的线程才能执行该代码块。

线程开始与结束的控制

public class ControllableThread implements Runnable{
    private boolean goon;

    public ControllableThread() {
        goon = false;
    }

    public void startRun() {
        goon = true;
        new Thread(this,"MyThread").start();
    }

    public void stopRun() {
        goon = false;
    }

    @Override
    public void run() {
        System.out.println("线程开始执行!");
        while(goon) {
            System.out.println("线程[" + Thread.currentThread().getName() + "]正在执行中……");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("线程[" + Thread.currentThread().getName() + "]运行结束!");
        }
    }
}

然后给出主控类控制线程的开始和结束就可以。

变量的寄存器优化

Java 变量的寄存器优化是一种编译器优化技术,用于将经常使用的变量存储在 CPU 寄存器中,以提高程序的执行效率。

在 Java 中,变量的寄存器优化是由 JIT 编译器负责实现的。JIT 编译器会在程序运行时对代码进行动态编译,将频繁使用的变量存储在 CPU 寄存器中,并且尽可能地减少变量的内存访问。这样可以避免频繁地从内存中读取变量值,从而提高程序的执行速度。

需要注意的是,变量的寄存器优化并不是一种一定会发生的优化,它取决于编译器对程序的分析和优化。在某些情况下,编译器可能会决定不进行寄存器优化,而是保持原有的内存访问方式。

另外,变量的寄存器优化只适用于局部变量和方法参数,对于实例变量和类变量,JIT 编译器无法进行寄存器优化。因为实例变量和类变量的访问需要通过对象引用或类名进行访问,无法直接存储在寄存器中。

总的来说,变量的寄存器优化是一种有效的优化技术,可以提高程序的性能。但是,它并不是万能的,需要根据具体情况进行使用,同时也需要注意编译器的优化策略可能会影响优化效果。

生产者——消费者问题分析

生产者-消费者问题是一个经典的线程同步问题,用于描述多个线程之间共享有限缓冲区时可能出现的问题。在该问题中,生产者线程负责向缓冲区中添加数据,而消费者线程负责从缓冲区中取出数据。由于缓冲区的大小是有限的,因此需要保证生产者和消费者线程之间的同步和互斥,避免出现死锁和数据竞争等问题。

下面是一个基本的生产者-消费者问题的实现:

public class ProducerConsumer {
    private List<Integer> buffer = new ArrayList<>();
    private int maxSize = 10;

    public void produce() throws InterruptedException {
        while (true) {
            synchronized (this) {
                while (buffer.size() == maxSize) {
                    wait();
                }
                Random rand = new Random();
                int num = rand.nextInt(100);
                buffer.add(num);
                System.out.println("Produced: " + num);
                notify();
            }
            Thread.sleep(1000);
        }
    }

    public void consume() throws InterruptedException {
        while (true) {
            synchronized (this) {
                while (buffer.size() == 0) {
                    wait();
                }
                int num = buffer.remove(0);
                System.out.println("Consumed: " + num);
                notify();
            }
            Thread.sleep(1000);
        }
    }
}

在上面的代码中,我们使用了一个 List 类型的 buffer 来模拟缓冲区,使用了 maxSize 变量来限制缓冲区的大小。生产者线程通过调用 produce() 方法向缓冲区中添加数据,消费者线程通过调用 consume() 方法从缓冲区中取出数据。

在每个方法中,我们使用了 synchronized 关键字来实现线程之间的同步。当一个线程进入 synchronized 块时,它会尝试获取 this 对象的锁,如果锁已经被其他线程占用,则该线程会进入阻塞状态,直到锁被释放为止。在生产者和消费者线程之间进行同步和互斥时,我们需要使用相同的锁对象,即 this。

在生产者和消费者方法中,我们都使用了一个 while 循环来判断缓冲区的状态。当缓冲区已满时,生产者线程会进入阻塞状态,等待消费者线程取走一些数据后再继续生产。当缓冲区为空时,消费者线程会进入阻塞状态,等待生产者线程添加数据后再继续消费。

在生产者和消费者线程之间进行同步时,我们使用了 wait() 和 notify() 方法。当一个线程调用 wait() 方法时,它会释放对象的锁,并进入阻塞状态,直到其他线程调用该对象的 notify() 方法通知它继续执行。notify() 方法会唤醒一个等待该对象锁的线程,使其继续执行。

​ 生产者-消费者问题是一个经典的线程同步问题,需要合理地控制线程之间的同步和互斥,避免出现死锁和数据竞争等问题。

相对完整的线程安全单例工厂模式

​ 线程安全单例工厂模式是一种创建单例对象的设计模式,它保证在多线程环境下只能创建一个对象,并且所有线程都可以访问该对象。

在 Java 中,可以通过使用 synchronized 关键字来实现线程安全的单例工厂模式。具体实现方式如下:

public class SingletonFactory {
    private static Singleton instance = null;

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

在上面的代码中,getInstance() 方法被 synchronized 修饰,保证在多线程环境下只有一个线程可以访问该方法,从而保证只能创建一个 Singleton 对象。如果在多线程环境下有多个线程同时访问 getInstance() 方法,其中一个线程会先获得锁并创建 Singleton 对象,其他线程会等待直到该线程释放锁为止。

需要注意的是,上面的代码虽然可以保证线程安全,但是每次调用 getInstance() 方法都需要获得锁,这会带来一定的性能损耗。为了避免这种情况,可以采用双重检查锁定(double-checked locking)的方式进行优化,即在获取锁之前先进行一次判断,如果 instance 不为 null,则无需获取锁直接返回该对象。具体实现方式如下:

public class SingletonFactory {
    private static volatile Singleton instance = null;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (SingletonFactory.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

在上面的代码中,使用了 volatile 关键字来保证 instance 的可见性,同时使用了双重检查锁定的方式进行优化,避免了每次获取锁的性能损耗。

总的来说,Java 线程安全单例工厂模式是一种常用的设计模式,它可以保证在多线程环境下只能创建一个对象,并且所有线程都可以访问该对象。需要根据具体情况选择适合的实现方式,并注意线程安全和性能问题。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

HB0o0

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值