在多线程应用程序中经常会遇到线程同步的问题。比如:两个线程A、线程B可能会 “同时” 执行同一段代码,或修改同一个变量。而很多时候我们是不希望这样的。
这时候,就需要用到线程同步。
多线程引发的问题
为了演示多线程引发问题,我们模仿买票,写一个简单的小程序。
- 实现Runnable模拟买票
public class SellTicket implements Runnable { //有30张票 private int tickets=30; public void run() { //写一个死循环,模拟在不断的卖票。 while (true){ //票数大于零,代表还有票,继续卖。 //如果票数小于等于零,也就是没票了。跳出循环,停止卖票 if(tickets > 0){ sell(); }else{ break; } } } //买票方法,模拟买票的动作 public void sell(){ //记录下现在的票数 int oldNumber = tickets; //卖掉一张后的票数,--tickets代表票数减一,模拟卖掉了一张票 int nowNumber = --tickets; System.out.println(Thread.currentThread().getName()+",卖出了第("+ oldNumber +")张票,还剩("+ nowNumber +")张票。"); } }
- 是线程模拟窗口买票
public class MyTest { public static void main(String[] args) { //使用同一个免票实例对象 //所以,由于下面所有的窗口都用的是这一个对象。所以他们的票也都是sellTicket的tickets属性。 SellTicket sellTicket=new SellTicket(); //模拟多个窗口同时买票,每个线程代表一个买票窗口 //Tread()的第二个参数,代表线程名。用线程名,模拟窗口名。 Thread window1=new Thread(sellTicket,"窗口-1"); Thread window2=new Thread(sellTicket,"窗口-2"); Thread window3=new Thread(sellTicket,"窗口-3"); Thread window4=new Thread(sellTicket,"窗口-4"); Thread window5=new Thread(sellTicket,"窗口-5"); //各个窗口开始工作 window1.start(); window2.start(); window3.start(); window4.start(); window5.start(); } }
> 输出: 窗口-3,卖出了第(28)张票,还剩(27)张票。 窗口-5,卖出了第(30)张票,还剩(29)张票。 窗口-1,卖出了第(26)张票,还剩(25)张票。 窗口-2,卖出了第(27)张票,还剩(26)张票。 ... ... 窗口-2,卖出了第(6)张票,还剩(5)张票。 窗口-5,卖出了第(4)张票,还剩(3)张票。 窗口-3,卖出了第(2)张票,还剩(1)张票。 窗口-4,卖出了第(0)张票,还剩(-1)张票。 窗口-2,卖出了第(1)张票,还剩(0)张票。
上面的例子模拟了多窗口买票,但是看,输出结果是不是又问题?怎么还有第(-1)张票。难道还有站票不成?当然不存在的,这是我们程序出现了问题。
这就是多线程同时操作统一参数的问题。也就是上面例子中SellTicket
对象的private int tickets=30;
属性。
p.s.我运行了好多遍都没出现错误的情况,后面给每个方法休眠了0.5秒才出现上面的错误结果。说明多线程不同步代码发生的错误不是百分之百的,只是有一定的概率。
为什么会出现上面这种情况?
众所周知,多线程的同步不是真的同步执行的。只是CPU切换运行线程所以看上去是几个线程同步执行。理解了这个概念往下看。(实在不懂的可以百度一下其他大佬的文章,我之后可能还会写一个笔记来记录-如果有必要的话)
- 我们一下最后三个输出结果,我们发现出错的是窗口4,也就是第四个线程。
...以上的可以省略,当然上面的也可能出现问题。比如两个不同的窗口卖出了同一张票。但是我没运行出来这个结果。。。。 窗口-3,卖出了第(2)张票,还剩(1)张票。 窗口-4,卖出了第(0)张票,还剩(-1)张票。 窗口-2,卖出了第(1)张票,还剩(0)张票。
- 出错的代码在下面。
代码运行步骤如下://在这里检测当前票数 if(tickets > 0){ sell(); }else{ break; }
-
到最后还剩一张票的时候。这时候 “窗口-4的线程” 运行了。它查看了一下当前票数,发现还有一张。然后进入到
if()
方法准备运行下面的代码。sell();
-
但是,“窗口-4的线程” 还没来得及运行
sell()
方法把票减一,或者运行到sell()
里面,还没来得及减去仅剩的一张票,这时候CPU把运行的权力送给了 “窗口-2的线程” 。此时票数还是1。 -
“窗口-2的线程” 动作比较快,它很快的检测当前票数是“1”,并进入if()方法,然后运行
sell();
将票数减一。 -
这时候再到 “窗口-4的线程” 此时票数就只剩下0了,但是他还是得运行sell()。也就只能
卖出了第(0)张票,还剩(-1)张票
了。
-
Java线程同步
为了避免上面的情况,可以使用以下三个方法来解决。
- 同步代码块
- 同步方法
- 同步锁
同步代码块
直接上代码
synchronized (this){
//这里的代码,只允许一个线程运行。
//等一个线程运行结束,把锁交给下一个等待的线程运行。
}
说明
1.this: 这里的this是同步锁也叫同步监听对象。
2.this可以是任何对象。
3.但是一般把当前多线程,并发访问的共同资源当作同步锁。在例子中也就是sellTicket对象。所以在对象里面可以写程this
。
修改上面的例子
public void run() {
//写一个死循环,模拟在不断的卖票。
while (true){
//票数大于零,代表还有票,继续卖。
//如果票数小于等于零,也就是没票了。跳出循环,停止卖票
synchronized (this.getClass()){
if(tickets > 0){
sell();
}else{
break;
}
}
}
}
同步方法
同步方法,其实跟同步代码块差不多,不过这个是在方法上添加synchronized
关键字。
public synchronized void sell(){
//这里的代码,只允许一个线程运行。
//等一个线程运行结束,把锁交给下一个等待的线程运行。
}
要在上面的例子中使用同步方法,需要改一下。把if()
判断放到sell()
方法里面。
public synchronized void sell(){
//在sell()方法在判断下当前票数
if(tickets > 0){
//记录下现在的票数
int oldNumber = tickets;
//卖掉一张后的票数,--tickets代表票数减一,模拟卖掉了一张票
int nowNumber = --tickets;
System.out.println(Thread.currentThread().getName()+",卖出了第("+ oldNumber +")张票,还剩("+ nowNumber +")张票。");
}
}
同步锁
以上两种方法,都需要一个关键字synchronized
。同步锁需要用到一个接口。
public interface Lock {
//生源n多代码,详细的去看源码。
}
接口里面有两个比较重要的方法。
/**
* 请求一个锁
* Acquires the lock.
*/
void lock();
/**
* 释放锁
* Releases the lock.
*/
void unlock();
在这两个方法之间的代码都是同步的。
但是Lock
只是个接口,没法使用。
JUC包给我们一个常用的实现类ReentrantLock
。部分源码如下:
public class ReentrantLock implements Lock, java.io.Serializable {
//感兴趣的可以去看看源码
}
修改我们的案例代码
public class SellTicket implements Runnable {
//有30张票
private int tickets=30;
Lock lock=new ReentrantLock();
public void run() {
//写一个死循环,模拟在不断的卖票。
while (true){
//票数大于零,代表还有票,继续卖。
//如果票数小于等于零,也就是没票了。跳出循环,停止卖票
lock.lock();
//synchronized (this.getClass()){
if(tickets > 0){
sell();
}else{
break;
}
//}
lock.unlock();
}
}
//买票方法,模拟买票的动作
public synchronized void sell(){
//记录下现在的票数
int oldNumber = tickets;
//卖掉一张后的票数,--tickets代表票数减一,模拟卖掉了一张票
int nowNumber = --tickets;
System.out.println(Thread.currentThread().getName()+",卖出了第("+ oldNumber +")张票,还剩("+ nowNumber +")张票。");
}
}
瞎总结
- 需要同步就要加上锁。代码锁了,其他线程就只能在门外等着。
- 无论是同步块、同步方法、锁机制。都有一个范围。前两个是在大括号中间
{}
,后者在两个方法中间lock();
和unlock();
- 但是加上同步的时候,里面的代码就只能由一个线程一次执行完。这样就违反我们使用多线程的目的。(俗称效率低下)
- 所以为了提高效率,我们要尽量想办法,把加锁的代码范围缩小,缩小,再缩小。(前提是程序不会因为多线程出问题,毕竟安全比效率重要)