如何使用synchronized关键字处理线程同步问题?【购票问题】

一、问题引入

多个线程实现买票功能:

public class MyTickRunnable implements Runnable {//MyTickRunnable实现了Runnable接口
    private int tick = 10;//总共剩余10张票
    
    @Override
    public void run() {//MyTickRunnable覆写了Runnable类的run()方法
        while(this.tick>0){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.tick--;
            System.out.println(Thread.currentThread().getName()+" 买票,剩余 "+this.tick);
        }
    }
 
 
    public static void main(String[] args) {
        Runnable runnable = new MyTickRunnable();//实例化MyTickRunnable类
        new Thread(runnable, "A").start();
        new Thread(runnable, "B").start();
        new Thread(runnable, "C").start();
    } 
}

由于Thread类有一个可以指定线程名称,而可以不使用系统默认分配线程名的构造方法,如下图所示,所以我们在创建线程的时候自定义线程名,以便区分。


 上述代码某一次运行结果如下:

 


显然,我们可以看到A,B,C三个线程买票,居然出现了负数的情况,而这种情况很明显跟生活中我们买票是相违背的。那么,接下来我们首先来看一下为什么会出现票为负数的情况?

首先,当我们实例化好 实现了Runnable接口的实现类 后,即runnable。此时将此runnable同时作为Thread的target创建A,B,C三个线程,也就是说,这三个线程同时处理同一逻辑,即它们三个同时进行买票操作。

其次,当三个线程调用start()方法被启动后,由于CPU调度具有不确定性,所以如果一个线程先于另外一个线程启动,并不意味着能先于另外一个线程执行,通过上面代码运行的结果就可以知道。

然后,当一个线程进入run()方法后,由于在while循环中调用了sleep方法,让此线程休眠了1000毫秒并交出了CPU。所以CPU可以接着执行其他线程,当第二个线程开始执行run方法并进入到while循环时,再次调用sleep方法,第二个线程休眠1000毫秒并交出CPU。接着CPU执行第三个线程,同样,当第三个线程执行run方法时,当进入到while循环后也会调用sleep方法休眠1000毫秒。此时当第三个线程交出了CPU后,估摸着第一个线程休眠的时间到了,所以第一个线程被唤醒继续执行。当第一个线程买完一次票后(this.tick--后thiis.tick为9),第二个线程休眠的时间也够了,所以轮到第二个线程执行,此时由于第二个线程已经进入到while循环了,所以第二个线程直接买票(this.tick--后this.tick为8),此时第三个线程休眠时间也到了,所以轮到第三个线程买票。一直如此,当有一个线程买完最后一张票后,此时this.tick为0。而由于其它两个线程已经进入到while循环了,即已经判断过this.tick>0了,所以即使有一个线程卖完了最后一张票,依旧不影响其它两个线程对this.tick进行减操作。所以才会出现票会负数的情况。总的来说,就是因为多个线程同时进入到了同一个方法并执行。我们称之为不同步操作。【同步:所有线程按照顺序一个一个进入到方法中执行,而不是一起进入到方法中执行】

所以现在为了解决多线程同时进入到某一个方法并执行而导致的不同步问题,我们引入了synchronized关键字,让线程按照顺序一个一个进入到方法中执行。


二、如何进行同步处理

两种方法:

  • 同步代码块 
  • 同步方法

先来介绍一下同步代码块:

同步代码块:锁定代码块,即保证同一时间只有一个线程执行代码块,此时需要设置一个锁对象,一般设置为当前对象this

public class MyTickRunnable implements Runnable {
    private int tick = 10;

        @Override
    public void run() {
        //1.同步块
        while(this.tick>0){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (this){//锁住当前对象,保证此时进入到下面代码块的只有一个线程
                if(this.tick>0){//当一个线程修改了this.tick后,另一个线程进来买票之前一定要再次判断是否还有票
                    this.tick--;
                    System.out.println(Thread.currentThread().getName()+" 买票,剩余 "+this.tick);
                }
            }
        }
    }


    public static void main(String[] args) {
        Runnable runnable = new MyTickRunnable();
        new Thread(runnable, "A").start();
        new Thread(runnable, "B").start();
        new Thread(runnable, "C").start();
    }


}

 此时我们可以看到程序某一次运行结果如下:


总结:通过上面代码我们发现同步代码块允许多个线程进入到run方法中,但是同一时刻只有一个线程进入到核心代码块中 


再来说说同步方法:

同步方法:直接对方法上锁,此时在同一时刻只有一个线程进入并执行此方法

public class MyTickRunnable implements Runnable {
    private int tick = 10;

    //2.同步方法
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            this.sale();
        }
    }
    public synchronized void sale() {//直接对方法上锁,保证同一时刻只有一个线程执行此方法
        if (this.tick > 0) { // 还有票
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.tick--;
            System.out.println(Thread.currentThread().getName()+" 买票,剩余 "+this.tick);
        }
    }


    public static void main(String[] args) {
        Runnable runnable = new MyTickRunnable();
        new Thread(runnable, "A").start();
        new Thread(runnable, "B").start();
        new Thread(runnable, "C").start();
    }


}

此时我们可以看到程序某一次运行结果如下:


三、关于synchronized的额外说明:

 不知道大家有没有发现上述两种方法锁的都是单一对象runnable,那这两种方法对锁多个runnable对象是不是也适用呢?我们来验证一下:

class Sync {
    public synchronized void test() {//同步方法
        System.out.println("test方法开始,当前线程为 "+Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("test方法结束,当前线程为 "+Thread.currentThread().getName());
    }
}
class MyThread extends Thread {
    @Override
    public void run() {//每执行一次run方法,就实例化一次Sync类,所以即使锁住了test方法,也只是对同一个实例化对象来说,对多实例化对象来说是无影响的
        Sync sync = new Sync() ;
        sync.test();
    }
}
public class Test {
    public static void main(String[] args) {
        for (int i = 0; i < 3 ; i++) {
            Thread thread = new MyThread() ;
            thread.start();
        }
    }
}

此时我们可以看到程序某一次执行结果如下:

咦,明明对test方法上了锁,为什么进入到方法中的线程有多个呢?

这是因为synchronized关键字对非静态方法上锁时锁的只是同一个对象的方法,即 当多个线程同时访问同一对象的同一方法时,synchronized锁住方法保证一次只有一个线程进入到此方法。而上面代码中,由于每次创建一个新线程并执行run方法时都重新实例化了类,即每个线程执行的是 不同对象的test方法。


总结一下:synchronized(this)和非static的synchronized方法只能防止多线程同时执行同一对象的同步方法,即synchronized(this)锁住的是对象,而不是代码;非static的synchronized方法锁的也是对象


那么我们该如何解决synchronized锁多对象,即锁住代码块的问题呢?

三种方法:

  • 修改上面的代码,锁住同一个对象
  • 锁住这个类对应的Class对象(利用反射机制)
  • 给要同步的方法同时加上static关键字和synchronized关键字,使之变成类方法

先来说说第一种方法: 修改上面的代码,锁住同一个对象

class Sync {
    public void test() {
        synchronized(this) {//锁住对象,保证同一时刻只有一个线程进入到代码块中
            System.out.println("test方法开始,当前线程为 " +
                    Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("test方法结束,当前线程为 " +
                    Thread.currentThread().getName());
        }
    }
}
class MyThread extends Thread {
    private Sync sync ;
    public MyThread(Sync sync) {
        this.sync = sync ;
    }
    @Override
    public void run() {
        this.sync.test();
    }
}
public class Test {
    public static void main(String[] args) {
        Sync sync = new Sync() ;//只实例化一次SYnc类,保证只有一个sync对象
        for (int i = 0; i < 3 ; i++) {
            Thread thread = new MyThread(sync) ;
            thread.start();
        }
    }
}

某一次运行结果如下:


第二种方法: 锁住这个类对应的Class对象(利用反射机制),即锁住Sync.class

class Sync {
    public void test() {
        synchronized(Sync.class) {//锁的是类而不是对象,实现了全局锁的效果
            System.out.println("test方法开始,当前线程为 " +
                    Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("test方法结束,当前线程为 " +
                    Thread.currentThread().getName());
        }
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        Sync sync = new Sync() ;
        sync.test();
    }
}
public class Test {
    public static void main(String[] args) {
        for (int i = 0; i < 3 ; i++) {
            Thread thread = new MyThread() ;
            thread.start();
        }
    }
}

某一次运行结果如下:


第三种方法: 给要同步的方法同时加上static关键字和synchronized关键字,使之变成类方法

class Sync {
    public static synchronized void test() {//类方法,通过类名.方法名调用

            System.out.println("test方法开始,当前线程为 " +
                    Thread.currentThread().getName());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("test方法结束,当前线程为 " +
                    Thread.currentThread().getName());

    }
}
class MyThread extends Thread {
    private Sync sync ;
    public MyThread(Sync sync) {
        this.sync = sync ;
    }
    @Override
    public void run() {
        Sync.test();//调用类方法
    }
}
public class Test {
    public static void main(String[] args) {
        Sync sync = new Sync() ;
        for (int i = 0; i < 3 ; i++) {
            Thread thread = new MyThread(sync) ;
            thread.start();
        }
    }
}

 某一次运行结果如下: 



总结:

经过上面的介绍后,我们将要锁的内容分为两类:锁方法和锁代码块。而不论是锁方法还是锁代码块又分为锁的是对象的方法或代码块还是类的方法或代码块。

锁方法:

  1. 锁对象的方法:public synchronized void 方法名(参数列表){...}

  2. 锁的方法:public static synchronized void 方法名(参数列表){...}

锁代码块:

  1. 锁对象的代码块:synchronized(this){...}

  2. 锁的代码块:synchronized(类名.class){...}

四、关于synchronized的底层实现

先以一段简单的代码为例:

public class Test{
    private static Object object = new Object();
    public static void main(String[] args) {
        synchronized (object) {
            System.out.println("hello world");
        }
    }
}

接着通过javap反编译看一下上述代码生成的部分字节码

public static void main(java.lang.String[]);
        descriptor: ([Ljava/lang/String;)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
        stack=2, locals=3, args_size=1
        0: getstatic #2 // Field object:Ljava/lang/Object;
        3: dup
        4: astore_1
        5: monitorenter
        6: getstatic #3 // Field
        java/lang/System.out:Ljava/io/PrintStream;
        9: ldc #4 // String hello world
        11: invokevirtual #5 // Method java/io/PrintStream.println:
        (Ljava/lang/String;)V
        14: aload_1
        15: monitorexit
        16: goto 24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return
        Exception table:
        from to target type
        6 16 19 any
        19 22 19 any

我们可以看到线程在执行同步代码块时要首先执行monitorenter指令,退出的时候执行monitorexit指令。

所以其实我们可以看出,使用synchronized关键字处理同步问题时,关键就是要对对象的监视器monitor进行获取,当线程获取到monitor之后才能继续往下执行,否则就只能等待。而获取对象的monitor监视器的这个过程又是互斥的,即同一时刻只有一个线程能获取到monitor。

另外,不知大家有没有发现上述字节码中包含一个monitorenter指令和多个monitorexit指令。这是因为Java虚拟机需要确保所获得的锁在正常执行路径和异常执行路径上都能够被解锁。

看完了同步代码块之后,我们再来看看同步方法吧。

以被synchronized修饰的test方法为例:

 public synchronized void test(){
        System.out.println("hello world");
    }

该方法生成的部分字节码如下:

 public synchronized void test();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_SYNCHRONIZED 
        Code:
        stack=2, locals=1, args_size=1
        0: getstatic #5 // Field
        java/lang/System.out:Ljava/io/PrintStream;
        3: ldc #6 // String hello world
        5: invokevirtual #7 // Method java/io/PrintStream.println:
        (Ljava/lang/String;)V
        8: return
        LineNumberTable:
        line 9: 0
        line 10: 8
        }

观察上述字节码,我们会在第三行发现方法的访问标记包括ACC_SYNCHRONIZED。该标记表示线程在进入到该方法时,Java虚拟机会执行monitorenter操作,而在退出时,不论是正常返回还是出现异常,Java虚拟机都会执行monitorexit操作。

另外,monitorenter和monitorexit操作对应的锁对象是隐式的。即对于实例方法来说,这两个操作对应的锁对象是this;对于静态方法(类方法)来说,这两个操作对应的锁对象则是所在类的Class实例。


关于monitorenter和monitorexit的作用,我们可以抽象地理解为每个锁对象都拥有一个锁计数器和一个指向持有该锁的线程的指针。

当执行monitorenter操作时,如果目标对象的计数器为0,表示它此时没有被其它线程所持有,在这种情况下,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将该锁对象的计数器加1。

在目标锁对象的计数器不为0的情况下,如果锁对象的持有线程为当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。【同一个线程再次获得锁时可以获取成功而其他线程获取锁会阻塞

当执行monitorexit时,Java虚拟机则需要将锁对象的计数器减1.当计数器减为0时,表示该锁已经被释放掉了。采用这种计数器的方式,可以允许同一个线程重复获取同一把锁。【如果一个类中有多个synchronized方法,那么这些方法之前的相互调用,不论直接还是间接,都会涉及对同一把锁的重复加锁操作】。因此,涉及一个可重入的特性,可以避免编程中的隐式约束。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值