导言
线程同步
是
Java高并发
的核心内容之一,
线程同步
目的是保证多线程以安全的方式按照一定的次序执行以满足对互斥资源的请求,从而协作完成某项任务,其重点是保证
线程安全
。
所谓
线程安全
,是多线程操纵共享数据的时候,对共享数据的操作不会出现
丢失修改
、
脏数据
等问题,或出现由于不满足
可见性
、
原子性
、
有序性
而产生的问题。例如,线程
A
让计数器
counter
的值
加1
的同时线程
B
让计数器
加1
,这就造成了丢失修改,与我们原本的目的向背驰:多线程对共享数据——计数器变量
counter
的修改应该排队进行。
这里先不讨论
丢失修改
、
脏数据
以及
可见性
等问题,因为这些是
Java高并发
的永恒主题,在以后的博客当中,一定会进行深入地讨论。
多窗口售票
多窗口售票是一个入门级别的线程同步案例,也是线程同步和线程安全的重要应用,有助于理解多线程的原子性、可见性等问题。
多窗口售票的需求描述:
利用多线程模拟3个窗口售出300张票,三个窗口同时售票(注意:
同时
指同一时间段,因此是
并发
的意思,不是
并行
),需要保证三个窗口不能出现
错票
、
重票
、
漏票
的问题。
需求分析:
- 多线程同步,一定存在共享数据,在这个案例当中,共享数据就是一定数量的票,可以用一个静态变量表示,或者用同一个以票数为属性的对象表示。
- 对共享数据的操作需要保证互斥,因此必须使用
,我们选择锁
来保证互斥访问共享数据,锁住的对象是可以是唯一的共享数据,或者其他唯一的对象也可以。synchronized
- 多线程的实现方式有多种,我们尝试其中两种:
、继承Thread类
。实现Runnable接口
方式一:继承Thread类
继承Thread类
实现多线程的步骤是:
- 继承
类,重写Thread
方法run()
- 创建子类对象,调用
方法启动子线程start()
每个窗口对应一个线程,因此需要一个线程类。在这个线程类当中,需要一个静态变量表示多个线程的共享资源,即剩余票数。另外,可以给每个窗口一个独特的窗口名字,用普通的属性即可。
代码如下:
package com.java.www.day20210102;
/**
* 每个窗口代表1个线程,因此我们定义窗口类作为Thread的子类
* */
class WindowThread extends Thread{
/**静态变量是类变量,因此可以作为多个对象的共享数据,并进行初始化*/
private static int ticketNumber = 300;
/**窗口名称*/
private String windowName;
/**在构造方法当中传入窗口名称*/
public WindowThread(String windowName){
this.windowName = windowName;
}
/**重写run()方法*/
@Override
public void run() {
while (true){
// 每次循环都准备获取票号,获取之前都要先获取锁,锁只要是唯一的就可以(step1)
synchronized (WindowThread.class){
if (ticketNumber>0){ // 获取都锁之后,检查是否还有余票(step2)
System.out.println(windowName+"售出票号为"+ticketNumber);
ticketNumber --;
}else{
break;
}
}
}
}
}
public class ticketTest {
public static void main(String[] args) {
// 创建三个窗口,并赋予名称
WindowThread window1 = new WindowThread("窗口1");
WindowThread window2 = new WindowThread("窗口2");
WindowThread window3 = new WindowThread("窗口3");
// 启动三个窗口开始售票
window1.start();
window2.start();
window3.start();
}
}
运行结果为:
窗口1售出票号为300
窗口1售出票号为299
窗口1售出票号为298
......
窗口1售出票号为273
窗口1售出票号为272
窗口1售出票号为271
窗口2售出票号为270
窗口2售出票号为269
窗口2售出票号为268
......
窗口2售出票号为215
窗口2售出票号为214
窗口2售出票号为213
窗口1售出票号为212
窗口1售出票号为211
窗口1售出票号为210
......
窗口3售出票号为3
窗口3售出票号为2
窗口3售出票号为1
Process finished with exit code 0
由于线程被CPU调度的随机性,多次运行结果一定不一致,但基本上可以看出,多个窗口在
并发
进行售票,而且没有
错票
、
重票
、
漏票
问题。
值得注意的是,获取锁(
step1
)和判断余票(
step2
)两步骤顺序不能颠倒,颠倒的话,会出现线程
A
和线程
B
都会判断有余票1张,之后线程
A
拿到锁售出最后一张票之后释放锁,之后线程
B
拿到锁,售出票号为
的票,这就出现了
错票
。
方式二:实现Runnable接口
使用
Runnable
接口实现多线程的方式是:
- 创建一个类实现
接口,实现Runnable
方法run()
- 创建实现类的对象,并将该对象传入
的构造方法创建子线程Thread
- 子线程对象调用
方法启动线程start()
这里
Runnable
接口的子类对象可以是唯一的,因此可以在这个子类当中定义一个变量,是否静态无所谓,因为这个子类只需要创建一个对象,多个窗口对象由
Thread
类创建。
代码如下:
package com.java.www.day20210102;
/**
* 这个类只需要一个实例化对象,多个窗口对象由Thread类实例化
* */
class WindowRunnable implements Runnable{
/**因为当前类只需要一个实例化对象,因此这个票数变量不需要是静态变量*/
private int ticketNumber;
/**既然ticketNumber不是静态的,就需要初始化*/
public WindowRunnable(int ticketNumber){
this.ticketNumber = ticketNumber;
}
public void run() {
while (true){
// 每次循环都准备获取票号,获取之前都要先获取锁,锁只要是唯一的就可以(step1)
synchronized (WindowRunnable.class){
if (ticketNumber>0){ // 获取都锁之后,检查是否还有余票(step2)
System.out.println(Thread.currentThread().getName()+"售出票号为"+ticketNumber);
ticketNumber --;
}else{
break;
}
}
}
}
}
public class ticketTest2 {
public static void main(String[] args) {
// 创建Runnable实现类的对象,给定票数
WindowRunnable windowRunnable = new WindowRunnable(300);
// 创建三个窗口子线程
Thread window1 = new Thread(windowRunnable);
Thread window2 = new Thread(windowRunnable);
Thread window3 = new Thread(windowRunnable);
// 设置窗口名称。因为Thread类是内置的,直接使用setName()
window1.setName("窗口1");
window2.setName("窗口2");
window3.setName("窗口3");
// 启动窗口开始售票
window1.start();
window2.start();
window3.start();
}
}
运行结果为:
窗口1售出票号为300
窗口1售出票号为299
窗口1售出票号为298
......
窗口3售出票号为3
窗口3售出票号为2
窗口3售出票号为1
由于线程被CPU调度的随机性,多次运行结果一定不一致,但基本上可以看出,多个窗口都在
并发
进行售票,而且没有
错票
、
重票
、
漏票
问题。
结语
- 两段代码当中,分别使用了
和WindowThread.class
作为被锁的对象,是唯一的。语法上,锁只要是唯一的都可以,例如改成WindowRunnable.class
也可以。但是,如果我们使用Thread.class
作为被锁住的对象(或者说是锁),同时其他内置的程序如果也使用了Thread.class
作为锁,就会降低程序执行的效率。所以,选择锁的时候,尽可能地保证这个对象不会被其他多线程程序所使用,并且保证唯一性。Thread.class
- 代码涉及的Java高并发三大原则:原子性、可见性、有序性暂时不讨论,目前只需要了解:
关键字保证了原子性、可见性,而上述案例当中不存在synchronized
而造成的无序性问题,因此只需要CPU指令重排
就足够了。synchronized