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

前言

多线程虽然在某些场景下提升了程序的性能,但当出现多个线程抢占(修改)同一个资源时,线程不安全性的问题就容易出现,造成重大损失。解决这种问题的方法之一就是同步,本篇文章中,将对线程的同步进行讲解,主要针对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多线程系列(三)——线程同步和锁的使用)