线程同步(JAVA笔记-线程基础篇)

在多线程应用程序中经常会遇到线程同步的问题。比如:两个线程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;
      }
    
    代码运行步骤如下:
    1. 到最后还剩一张票的时候。这时候 “窗口-4的线程” 运行了。它查看了一下当前票数,发现还有一张。然后进入到if()方法准备运行下面的代码。

      sell();
      
    2. 但是,“窗口-4的线程” 还没来得及运行sell()方法把票减一,或者运行到sell()里面,还没来得及减去仅剩的一张票,这时候CPU把运行的权力送给了 “窗口-2的线程” 。此时票数还是1。

    3. “窗口-2的线程” 动作比较快,它很快的检测当前票数是“1”,并进入if()方法,然后运行sell();将票数减一。

    4. 这时候再到 “窗口-4的线程” 此时票数就只剩下0了,但是他还是得运行sell()。也就只能卖出了第(0)张票,还剩(-1)张票了。

Java线程同步

为了避免上面的情况,可以使用以下三个方法来解决。

  1. 同步代码块
  2. 同步方法
  3. 同步锁

同步代码块

直接上代码

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();
  • 但是加上同步的时候,里面的代码就只能由一个线程一次执行完。这样就违反我们使用多线程的目的。(俗称效率低下)
  • 所以为了提高效率,我们要尽量想办法,把加锁的代码范围缩小,缩小,再缩小。(前提是程序不会因为多线程出问题,毕竟安全比效率重要)
上一篇:POJ2828 Buy Tickets(线段树二分)


下一篇:多线程