线程与同步锁
前言
这是对线程安全和线程通信的一个小结。
在这之前先回顾一下有关线程的基础知识
线程的五个状态
1.新建状态
2.运行状态
3.阻塞状态
4.死亡状态
5.休眠状态
6.无限(永久)等待状态
其关系如图(截图自黑马教程)
创建线程方式
1.继承Thread类
2.实现Runnable接口
//接口的实现类
public class ThreadImpl implements Runnable {
@Override
public void run() {
//重写的run方法体
}
}
//在main方法中创建线程
ThreadImpl p = new ThreadImpl();
new Thread(p).start(); //相当于创建了一个p类线程
什么是线程安全
当两个(及以上)线程对同一数据进行处理时,如果不加同步锁,就会出现数据污染的情况,这样的线程是不安全的。
显然在实际操作过程中,我们不想让数据被污染(错误处理),因为这不是我们的目标,所以我们需要在处理同一数据时,确保程序的线程安全。
线程安全产生的原因
我们以窗口售票为例。下面是不加同步锁,即线程不安全的情况。
//线程的实现类
public class ThreadImpl implements Runnable {
//创建100张票
private int ticket = 100;
@Override
public void run() {
//创建死循环,一直售票
while(true){
//当有票时才售票
if(tickit > 0){
//这步trycatch只是为了提高产生数据污染的概率
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//售票
System.out.println("正出售第"+tickit+"张票");
ticket--;
}
}
}
}
//主方法
public class Demo{
public static void main(String[] args) {
ThreadImpl p = new ThreadImpl();
Thread a = new Thread(p);
Thread b = new Thread(p);
Thread c = new Thread(p);
a.start();
b.start();
c.start();
}
}
本例中模拟了三个窗口售票的过程,创建了对象p,并用p创建了三个不同的线程。由于传入的参数相同,因此三个线程处理的数据是同一个tickit(对象p中的ticket)。
这个程序的目标是,将票从100->1按顺序输出。
运行结果如图,这并没有达到我们的目标:
在分析数据被污染的原因之前,我想先聊一聊cpu在执行各个线程时cpu的风骚操作。
cpu在遇到多个线程并发的情况时,它会在多个线程中随机的来回快速切换。不管是否执行完当前线程的任务,它都会跑去其他线程那看看有没有活儿干。
打个比方。男子A(cpu的执行权)有三个女友BCD(程序创建的三个线程)。BCD都希望A能够从在一起开始一直留在他们身边,和她们一直约会到分手(run方法)。但是第一天A选择了与C约会(cpu执行C线程),第二天A选择与B约会(cpu执行B线程),如此往复…
直到A与B分手(B线程死亡,即线程任务执行完毕)后,A再也不会和B约会,从此,A只会与C,D约会。当A与CD都分手后,程序就结束了。
在这里对cpu的执行作个小节
cpu的执行,并不是对着同一个线程执行到线程关闭,才切换到另一个线程的。
看到这里,我想上述数据被污染的原因就显而易见了。
由于无法捕捉到cpu的具体运动轨迹,我只能通过模拟来说明原因。
假设cpu在刚开始选择了线程b,并执行线程b中的run方法。当cpu执行到线程b中的输出语句时,突然跳转到线程a开始执行方法。注意:此时线程b中的ticket–并没有被执行,所以ticket的值依旧是100。 因此,在线程a中输出的语句,依旧是“正在出售第100张票”。
解决线程安全问题的办法
想要解决线程安全问题,需要用到同步技术,即利用锁对象保护数据不被污染。
我们对上面售票的例子进行修改
public class ThreadImpl implements Runnable {
//创建100张票
private int ticket = 100;
Object obj = new Object();
public ThreadImpl(Object obj) {
this.obj = obj;
}
@Override
public void run() {
while(true){
synchronized (obj){
if(tickit > 0){
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("正出售第"+tickit+"张票");
ticket--;
}
}
}
}
}
public class Demo{
public static void main(String[] args) {
//创建一个锁对象
Object obj = new Object();
ThreadImpl p = new ThreadImpl(obj);
//创建三个线程(注意:传入线程的锁对象是同一个!!!)
Thread a = new Thread(p);
Thread b = new Thread(p);
Thread c = new Thread(p);
//启动线程
a.start();
b.start();
c.start();
}
}
运行结果:
对比两组代码,发现改进后的代码中,run方法中while内的代码块被放入了synchronized同步体内。这使得cpu在选择线程后,必须执行完同步体内的内容,才会去继续选择下一个线程。
那这是怎么实现的呢?
观察主函数发现,创建的三个线程中,Object对象是同一个obj,并且,在同步体中的锁对象就是obj,这使得三个线程的同步体锁对象唯一。
同样的,通过假设来模拟一下cpu在同步体内的执行过程。
cpu在一开始选择了线程a后,进入了同步体,并带走了锁对象obj,开始执行同步体内的代码。这里需要注意一个地方,当run执行到sleep()时,线程会释放cpu的执行权(但是不会归还锁对象),此时cpu又去找到了线程2,想执行线程b中的代码,但它发现,同步体中已经没有锁对象了(锁在线程a身上),于是它便只能等候线程a归还锁(即线程阻塞)。sleep()结束后,cpu将继续执行线程a的内容。 当线程a内容执行完毕后,将归还锁对象,释放cpu的执行权。于是,cpu开始选择下一个线程,如此往复直至所有线程死亡。
小节:同步技术使得只允许在同一个时间只有一个线程执行相同锁对象的同步体中代码,即使是两个不同的线程,且其同步体中的代码不同。
到这里还没有结束,我们需要利用线程通信,来更清楚的了解锁、cpu执行权和线程之间的关系。
什么是线程通信
线程通信指的是两个线程在处理同步体时互相沟通的过程。其关键在于wait()方法和notify()(notifyAll())方法。
如何实现线程通信(等待唤醒机制)
以包子铺为例。创建一个包子类,用来记录卖家店里是否拥有包子,如果卖家有包子,卖家线程将停止(wait),让买家线程买光包子。如果买家线程发现没有包子,买家就通知卖家线程做包子。
在这个例子中,包子类中的flag就是共享数据,且包子类是同步体的锁对象。
//创建一个包子类
public class BaoZi {
boolean flag = false; //false没包子 true有包子
}
//创建卖家线程
public class Seller extends Thread {
private BaoZi bz;
public Seller(BaoZi bz) {
this.bz = bz;
}
@Override
public void run() {
while(true){
//使用同步
synchronized (bz) {
//如果有包子就运行买家线程
if (bz.flag == true) {
try {
bz.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("正在生产包子");
//等待3s做包子
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("包子做好了");
//修改包子库存的状态
bz.flag = true;
//唤醒买家线程
bz.notify();
}
}
}
}
//创建买家线程
public class Buyer extends Thread{
private BaoZi bz;
public Buyer(BaoZi bz) {
this.bz = bz;
}
@Override
public void run() {
while(true){
synchronized (bz){
//如果没包子就运行卖家线程
if(bz.flag == false){
try {
bz.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//买到包子,修改包子库存状态
System.out.println("买到了包子");
bz.flag = false;
//唤醒卖家线程
bz.notify();
}
}
}
}
public class Main {
public static void main(String[] args) {
BaoZi bz = new BaoZi();
new Seller(bz).start();
new Buyer(bz).start();
}
}
在分析整个流程流程之前,我们需要搞清楚wait()和sleep()之间的差别
先看一下wait和sleep的API文档介绍。
wait() 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待。
sleep(long millis) 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行)。
tips:这里就不做分析了,直接贴出结果。
1.wait()方法是Object类的方法,sleep()是Thread类的方法。(这个差异于线程通信的无关)
2.wait()方法必须放在同步体内,不然会使得线程进入无限等待状态。sleep无相关要求。
3.wait()方法会使得线程归还锁对象,sleep()不会。(这是线程通信的关键)
至此,我们可以来分析一下这个程序的整个流程。
假设cpu首先选择的线程是买家线程,由于bz.flag初始化为false,买家线程将执行wait(),进入等待状态,并归还锁对象,释放cpu的执行权。此时cpu进入到卖家线程,发现锁对象已被归还,就带着锁进入卖家同步体执行相关程序。直到执行完bz.flag = true后,唤醒买家线程(注意,仅仅只是唤醒了买家线程!其他什么都没做,买家线程将进入到阻塞状态!),等待执行完同步体内的代码后,归还锁对象,释放cpu执行权,完成一次通信。
到此为止已经将线程安全和通信最表层的问题讲完了,但是我还是要对wait多提一句。
Java中wait()方法为什么要放在同步块中?(lost wake-up问题)
本部分参考自博客:
Java中wait()方法为什么要放在同步块中?(lost wake-up 问题)
这不仅仅在Java中会遇到,在几乎所有多线程场景里都会遇到这种lost wake-up problem。
//线程1中run方法
System.out.print("我是线程1,我要叫醒线程2,然后我睡觉,等线程2来叫我");
obj.notify();
obj.wait();
//线程2中run方法
System.out.print("我是线程2,我要叫醒线程1,然后我睡觉,等线程1来叫我");
obj.notify();
obj.wait();
如果不加同步体,就会造成以下情况。
线程1说:“我要睡了。”
于是赶紧叫醒线程2。
但是此时线程2还没睡,它本来就是醒着的。
这时候线程2说:“我也要睡了。”
于是赶紧叫醒线程1。
但这时线程1也还没睡,它也是醒着的。
他们俩对对方的叫醒都被对方忽略了。
紧接着,线程1睡了。
然后,线程2也睡了。
两个人就再也没有醒过(因为已经没有线程来notify他们了)。