美文网首页
Java线程<第三篇>:线程安全与同步

Java线程<第三篇>:线程安全与同步

作者: NoBugException | 来源:发表于2022-04-17 10:26 被阅读0次

    在多线程情况下,如果存在一个数据被多个线程同时共享,那么这个共享数据如果不做特殊处理,就容易出现紊乱。
    这个特殊处理就是添加同步。

    就拿售票来举例,代码如下:

    public class TicketRunnable implements Runnable{
    
        private int currentCount = 0; // 当前已售出票数
    
        private static final int MAX = 10; // 最大10票
    
        @Override
        public void run() {
            while (currentCount < MAX) {
                currentCount = currentCount + 1;
                String threadName = Thread.currentThread().getName();
                System.out.println(threadName + "卖出了第 " + currentCount + "票");
            }
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
    
        TicketRunnable ticketRunnable = new TicketRunnable();
    
        Thread thread1 = new Thread(ticketRunnable, "一号窗口");
        Thread thread2 = new Thread(ticketRunnable, "二号窗口");
        Thread thread3 = new Thread(ticketRunnable, "三号窗口");
        Thread thread4 = new Thread(ticketRunnable, "四号窗口");
    
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
    

    输出结果是:

    二号窗口卖出了第 3 票
    二号窗口卖出了第 5 票
    四号窗口卖出了第 4 票
    三号窗口卖出了第 3 票
    三号窗口卖出了第 8 票
    三号窗口卖出了第 9 票
    三号窗口卖出了第 10 票
    一号窗口卖出了第 3 票
    二号窗口卖出了第 6 票
    

    以上输出结果肯定是存在问题的,因为第1、2、7票到底是在哪个窗口卖出,根本就不知道,并且有些被多次打印,显然数据发生了紊乱。

    我们可以观察,currentCount 是一个共享数据,它的取值被多个线程所影响,当多个线程同时操作同一个数据时,需要考虑线程安全问题。

    为了解决数据线程安全问题,可以采用同步(synchronized)的方式来解决。

    改进后的代码如下:

    @Override
    public void run() {
        synchronized (TicketRunnable.class) {
            while (currentCount < MAX) {
                currentCount = currentCount + 1;
                String threadName = Thread.currentThread().getName();
                System.out.println(threadName + "卖出了第 " + currentCount + " 票");
            }
        }
    }
    

    使用 synchronized 关键字,将 currentCount 变量保护起来可以解决线程安全问题,最终打印结果如下:

    一号窗口卖出了第 1 票
    一号窗口卖出了第 2 票
    一号窗口卖出了第 3 票
    一号窗口卖出了第 4 票
    一号窗口卖出了第 5 票
    一号窗口卖出了第 6 票
    一号窗口卖出了第 7 票
    一号窗口卖出了第 8 票
    一号窗口卖出了第 9 票
    一号窗口卖出了第 10 票
    

    从以上输出结果可以看出,synchronized 是有用的,售出的票数可以按照顺序依次售出。
    但是,在代码设计的过程中,还要考虑合理性,从打印结果上看到一个很奇怪的现象,为什么所有的票都是在一号窗口售出的?如果多运行几次就会发现,所有的票总是由某一个具体窗口售出(具体是哪个窗口售出是CPU决定的)。

    synchronized 是阻塞性的,为了防止其它线程进入而设置了一把锁,假如某一个线程抢到了优先执行权,那么这个线程会首先进入while循环,当while执行完毕之后,所有的票已经被全部售出,已经没有其它线程什么事了,所以,这段代码需要有优化,优化后的代码如下:

    public class TicketRunnable implements Runnable{
    
        private volatile int currentCount = 0; // 当前已售出票数
    
        private static final int MAX = 10; // 最大10票
    
        @Override
        public void run() {
            while (currentCount < MAX) { // ----(1)
                synchronized (TicketRunnable.class) { // ----(2)
                    if (currentCount < MAX) { // ----(3)
                        currentCount = currentCount + 1;
                        String threadName = Thread.currentThread().getName();
                        System.out.println(threadName + "卖出了第 " + currentCount + " 票");
                    }
                }
            }
        }
    }
    

    为了方便讲解,在代码中,标记了(1)、(2)、(3),执行到(2)时,第一个抢到CPU资源的线程会被获得同步锁,接下来会直接执行(3),其它线程执行完(1)之后,被(2)挡住(被锁挡住),只有等待持有锁的线程开锁之后其它线程才可以继续执行。所以,以上代码是非常安全的。需要注意的是,为了防止指令重排,需要将共享变量加上 volatile 关键字。

    使用 synchronized 关键字需要知道,synchronized 代码块具有互斥性,只能有一个线程获取了monitor 锁,其它线程只能进入阻塞状态,等待获取 monitor 锁的线程对其进行释放。

    synchronized 的同步锁可以指定一个class,也可以是一个对象,需要注意的是:

    (1)与 monitor 关联的对象不能为空;
        对象直接和 monitor 关联,如果对象为空,就没有 monitor 可言。
    (2)synchronized 控制的范围不能过大;
        synchronized 控制的范围不能过大,synchronized 会阻塞其它线程执行,
        如果 synchronized 控制的范围过大会消耗大量的CPU资源,导致效率降低,
        原则上,synchronized 尽量只作用于共享资源(共享数据);
    (3)每一个对象只有一个 monitor,如果不同的 monitor 锁相同的方法,那么就无法做到互斥效果,比如:
    
            synchronized (new Object()) {}
    
    每次执行到 synchronized 代码块的时候都会重新创建新的对象,由于一个对象只有一个 monitor,
    所以每次执行到 synchronized 代码块的时候次,monitor 也不相同,
    所以,那些线程是相互独立的,并没有互斥这个特性。
    
    在实际开发中,synchronized 传入的往往不是直接 new 一个对象,而是在这之前已经 new 过的对象,
    这时,就必须详细分析那些不同来源的对象是否是同一个对象了。
    如果N个线程的 synchronized 传入的是同一个对象,那么这N个线程持有相同的 monitor 锁,这样才是线程安全的。
    
    (4)需要防止线程死锁的情况
    
        【1】多个线程持有一把锁(monitor 相同),并且至少有一个线程无限循环,演示代码如下:
    
            public static void main(String[] args) throws InterruptedException {
                Object object = new Object();
                Thread thread1 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        Thread thread = Thread.currentThread();
                        System.out.println("开始执行 " + thread.getName() + " 线程");
                        synchronized (object) {
                            while (true) {
        
                            }
                        }
                    }
                });
                Thread thread2 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        Thread thread = Thread.currentThread();
                        System.out.println("开始执行 " + thread.getName() + " 线程");
                        synchronized (object) {
        
                        }
                        System.out.println(thread.getName() + " 线程执行结束");
                    }
                });
                thread1.start();
                thread2.start();
            }
    
        从代码上可以知道,两个线程持有一把锁,第一个线程有一个while无限循环,这样导致的结果是,
        如果CPU首先执行第一个线程,第二个线程需要等待第一个线程释放锁之后才能执行,这样就形成的“死循环”。
        所以,线程中的代码千万不能有死循环,否则容易死锁,“耗时操作”尽量也不要有,因为在复杂的项目架构中,
        线程中的耗时操作也有可能因为某个原因导致其它线程形成死锁,如果没有死锁,也会对项目的性能造成严重的影响。
    
        【2】不允许出现交叉锁
    
        交叉锁容易导致死锁,什么是交叉锁呢?来,演示代码:
    
            public static void main(String[] args) throws InterruptedException {
                Object object1 = new Object();
                Object object2 = new Object();
                Thread thread1 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        Thread thread = Thread.currentThread();
                        System.out.println("开始执行 " + thread.getName() + " 线程");
                        synchronized (object1) {
                            try {
                                TimeUnit.SECONDS.sleep(1);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            synchronized (object2) {
        
                            }
                        }
                        System.out.println(thread.getName() + " 线程执行结束");
                    }
                });
                Thread thread2 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        Thread thread = Thread.currentThread();
                        System.out.println("开始执行 " + thread.getName() + " 线程");
                        synchronized (object2) {
                            try {
                                TimeUnit.SECONDS.sleep(1);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            synchronized (object1) {
        
                            }
                        }
                        System.out.println(thread.getName() + " 线程执行结束");
                    }
                });
                thread1.start();
                thread2.start();
            }
    
        线程1的关键代码是:
    
                synchronized (object1) {
                    synchronized (object2) {
                    }
                }
    
        线程2的关键代码是:
    
                synchronized (object2) {
                    synchronized (object1) {
                    }
                }
        
       两个线程各持有了两把锁,两把锁的顺序是相反了,这就是`交叉锁`,在实际项目中,需要排查交叉锁的情况,因为它是一个危险的因素。
    
        【3】还有其它情况可能会导致死锁,比如:内存不足、一问一答式的数据交换、数据库锁、文件锁等。
    

    synchronized 还可以放在方法上,和 synchronized 代码块的作用是一致的:

    public synchronized void method() {
    
    }
    

    最后,了解一下 this monitor 和 class monitor 的区别 ?

    什么是 this monitor?
    
        public synchronized void method1() {
    
        }
    
    或
    
        public void method2() {
            synchronized (this) { // this 可以换成其它对象
    
            }
        }
    
    method1 和 method2 都不是静态方法,使用 this monitor 即可;
    
    什么是 class monitor?
    
        public static synchronized void method1() {
    
        }
    
    或
    
        public static synchronized void method2() {
            synchronized (XXX.class) {
    
            }
        }
    
    method1 和 method2 都是静态方法,使用 class monitor 即可;
    

    this monitor 和 class monitor 虽然在写法上有所区别,但是本质的功能是完全一致的。

    [本章完]

    相关文章

      网友评论

          本文标题:Java线程<第三篇>:线程安全与同步

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