美文网首页
Java多线程系列(三)——线程同步和锁的使用

Java多线程系列(三)——线程同步和锁的使用

作者: moutory | 来源:发表于2021-05-11 16:25 被阅读0次

    前言

    多线程虽然在某些场景下提升了程序的性能,但当出现多个线程抢占(修改)同一个资源时,线程不安全性的问题就容易出现,造成重大损失。解决这种问题的方法之一就是同步,本篇文章中,将对线程的同步进行讲解,主要针对synchronized关键字的使用进行演示,同时将对类锁对象锁二者的概念和使用进行分析,希望对各位读者有所帮助。


    一、多线程为什么需要同步

    我们在之前的文章中已经了解到,多线程可以更加充分地利用硬件设备,提高程序的运行效率,多线程的优势这么大,为什么需要加入同步这一个概念呢?我们来看下面这个经典的抢票案例。

    火车站一共有10张票,小红,小明,小蓝三个人都在不停的抢票,直到抢到最后一张票结束。下面是具体的代码演示:

    // 火车站类
    class Train {
        private int ticketNum = 10;
        private boolean flag = true;
    
        public  void buy() {
                while (flag) {
                    if (ticketNum <= 0) {
                        flag = false;
                        return;
                    }
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "抢到了票,剩余票数为"+ --ticketNum);
                }
            }
    }
    // 测试类代码
    public class TicketTest {
    
        public static void main(String[] args) {
            Train train = new Train();
            Thread thread1 = new Thread(()->{
                train.buy();
            },"小明");
            Thread thread2 = new Thread(()->{
                train.buy();
            },"小蓝");
            thread1.start();
            thread2.start();
        }
    }
    

    为了模拟并发效果,我们让票数真正扣减之前,让线程睡眠100毫秒。运行程序后,结果参考如下:

    无同步状态下抢票结果
    我们可以看到,出现了剩余票数为-1的情况,最终运行的结果超过了我们允许的阀值,这就是高并发下可能存在的问题。(其实仔细观察后还能发现,程序执行过程中还出现两个线程抢到票后剩余票数一致的情况,这也是高并发下的漏洞)。
    我们仔细想想,为什么会出现上面这种问题呢?
    其实本质上是因为高并发下,多个线程轮流抢占CPU资源,都通过了方法对资源的限制判断后,而后再对资源进行了修改,此时就会出现资源消耗超过阀值的问题。

    知道了问题的存在,那么可以怎么样去解决这个问题呢?
    答案很简单,就是排队。也就是说,但多个线程要调用某个方法时,最先调用方法的线程就锁定了这个方法,其他线程调用这个方法时发现已经有其他线程调用这个方法,就会进入阻塞状态,等待最先调用的线程执行完方法后再抢占调用该方法的权利。
    举个类似的例子,像是公园里面有100个人但只有1个厕所,当第一个上厕所的人把门锁上后,其他人就都上不了厕所了,只有当第一个人上完厕所后,其他人才能上。而映射到代码上,厕所的门锁就是代码中的监控锁,上厕所的人需要判断当前厕所是否已经有人,这种排队等待的概念就是同步。

    二、synchronized关键字的使用和锁的引入

    我们在第一节中讲到了同步,与其相对应的关键字就是synchronized。对于一个对象的方法, 如果没有synchronized关键字修饰, 该方法可以被任意数量的线程,在任意时刻调用。对于添加了synchronized关键字的方法,任意时刻只能被唯一的一个获得了对象实例锁的线程调用。
    这里我们提到了对象实例锁,这是什么东西呢?可以理解为Java中的每一个对象都有一把唯一的内置锁,我们利用这个特性,常常把对象实例锁配合synchronized一起使用。也可以这么理解,synchronized指明了某段方法需要同步,但总得需要一个标识来告诉其他线程,当前这个方法内已有其他线程调用了,这就是锁存在的意义。
    下面我们就来使用synchronized关键字来解决我们上一节遇到的高并发问题。

    class Train {
    
        private int ticketNum = 10;
        private boolean flag = true;
    
        public synchronized void buy() {
                while (flag) {
                    if (ticketNum <= 0) {
                        flag = false;
                        return;
                    }
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "抢到了票,剩余票数为"+ --ticketNum);
                }
            }
    }
    

    实际上,我们改动的地方只有一处,就是给buy()方法加上了synchronized关键字。可能有些读者会疑问,不是说synchronized 关键字需要配合锁来使用吗,怎么这里没看到?其实是因为当synchronized关键字修饰普通方法时,默认把this作为锁的对象。我们来看一下结果:

    加上锁后的运行结果

    三、对象锁和类锁的区别和使用

    我们在第二节中使用了synchronized关键字来实现了同步。但实际上,synchronized关键字修饰的方式有很多种,简单划分的话可以分为下面2种:

    (1)用于方法上:静态方法和普通方法
    public class LockA {
        public synchronized static void methodA(){
            ...
        }
        public synchronized void methodB(){
           ...
        }
    }
    

    这两者看上去很相似,但实际上用法却大不一样。前者静态方法是多个对象间共享的,而后者普通方法则是每个对象独占的。

    (2)用于代码块上
    class LockB {
        public void methodA() {
            synchronized (this) {
              ...
            }
        }
    
        public synchronized void methodB() {
            synchronized (LockB.class){
                     ...
            }
        }
    }
    

    我们可以看到,上面的例子中,区别只在于代码块中锁的对象不同。实际上this表示当前LockB的实例对象,而LockB表示的是LockB这个类。

    类锁

    实际上,根据锁的对象不同,还可以分类为类锁和对象锁。多个类对象共享一个class对象,共享同一组的静态方法,使持有者可以同步地调用静态方法。当synchronized指定修饰静态方法或者class对象的时候,拿到的就是类锁。

    class LockC {
        public synchronized static void methodA() {
             ...
        }
    
        public synchronized void methodB() {
            synchronized (LockB.class){
            ...
            }
        }
    }
    

    上面这个类中,虽然synchronized修饰的地方不同,但实际上锁的对象都是同一个。所以如果二者同时调用,是可以同步成功的。

    对象锁

    对象锁,是用来对象的,虚拟机为每个的非静态方法和非静态域都分配了自己的空间,不像静态方法和静态域,是所有对象共用一组。所以synchronized修饰非静态方法或者this的时候拿到的就是对象锁。

    class LockD {
        public synchronized void methodA() {
          ...
        }
    
        public synchronized void methodB() {
            synchronized (this){
            ...
            }
        }
    }
    

    上面的代码中,其实使用的锁都是当前对象。二者实现的效果差不多,只是说代码块要更加灵活一些。

    需要注意的是,类锁和对象锁的锁对象不同,所以使用起来要小心混淆,防止出现锁不生效的情况。

    public class TicketTest {
    
        public static void main(String[] args) {
            Train train = new Train();
            Thread thread1 = new Thread(()->{
                train.method1();
            },"小明");
            Thread thread2 = new Thread(()->{
                train.method2();
            },"小蓝");
            thread1.start();
            thread2.start();
        }
    }
    
    class Train {
    
        private int ticketNum = 10;
        private boolean flag = true;
    
        public synchronized void method1() {
            buy();
        }
        public void method2(){
            synchronized (this){
                buy();
            }
        }
    
    
        private void buy() {
            while (flag) {
                if (ticketNum <= 0) {
                    flag = false;
                    return;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "抢到了票,剩余票数为"+ --ticketNum);
            }
        }
    }
    
    对象锁演示结果

    比如上面的代码中,由于两个方法的锁对象都是自身,所以不会出现小蓝或者小明双方都抢到票的情况。但如果是下面这种情况,一个对象锁一个类锁,那么同步就会不起作用了。

    class Train {
    
        private int ticketNum = 10;
        private boolean flag = true;
    
        public synchronized void method1() {
            buy();
        }
        public void method2(){
            synchronized (Train.class){
                buy();
            }
        }
        private void buy() {
          ...
        }
    }
    
    类锁+对象锁导致同步失效

    因此在实际使用中,我们要清楚当前同步的锁对象具体是谁,避免出现有加synchronized关键字但还是同步失败的乌龙。

    四、注意事项

    实际上,我们可以把线程的同步机制理解为是使某一段代码串行化运行,但同步机制虽好,但却不应过度使用。原因也很简单,使用多线程的目的就是为了加快线程的运行效率,如果过度使用同步机制,那么也就丧失了我们利用多线程优势的初衷。
    因此,在开发中我们需要注意,对于需要同步的方法块,我们尽量要做到细颗粒化。比如一个方法中有一部分是无直接关系的查询功能,一部分是修改功能,那么针对整个方法进行同步就不太合适,我们要尽可能地只对修改功能部分的代码进行同步即可。


    image.png

    参考文章:
    synchronized的修饰方法和修饰代码块区别
    https://blog.csdn.net/TesuZer/article/details/80874195

    相关文章

      网友评论

          本文标题:Java多线程系列(三)——线程同步和锁的使用

          本文链接:https://www.haomeiwen.com/subject/fzngdltx.html