文章目录
- Java 多线程编程
- 前言
- 几个关键的基本概念(这里涉及《操作系统》的基础知识)
- Java 线程编程
- 线程编程语法示例
- 两个相关线程
- 对象锁
- 线程的互斥
- volatile 关键字
- 线程的同步
- wait() 和 notify()
- synchronized
- synchronized 修饰方法
- 线程开始与结束的控制
- 变量的寄存器优化
- 生产者——消费者问题分析
- 相对完整的线程安全单例工厂模式
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 线程安全单例工厂模式是一种常用的设计模式,它可以保证在多线程环境下只能创建一个对象,并且所有线程都可以访问该对象。需要根据具体情况选择适合的实现方式,并注意线程安全和性能问题。