线程同步代码块

不仅Java语言,任何编程语言都一样,两个线程同时访问同一个变量时就可能会出现线程安全问题,这是因为两个线程对变量的访问不是原子操作,在线程切换时会造成混乱。解决线程安全问题的办法就是使用线程同步,让一个有线程安全问题的非原子性操作只能串行执行。Java语法提供了synchronized关键字实现了同步代码块,能够解决线程安全问题。除此之外,Java还提供了Lock锁接口,能够对线程同步逻辑进行更为精细的控制,有关Lock接口的内容将在后续章节介绍。

当然,线程同步的缺点也显而易见,就是影响了并行性能。因此,兼顾性能和安全是需要良好的软件架构设计能力的。

同步函数

Java语言中,在函数(方法)定义前面加上synchronized后,函数就会变成同步函数。我们看这样一个例子:某函数封装了一部分对变量的访问操作,这个函数是多线程运行的且操作的变量是线程共享的,我们希望这个函数是线程安全的,此时在函数定义时加上synchronized关键字即可。

例子:

MyThread.java

package com.gacfox.demo;

public class MyThread implements Runnable {
    private int i = 0;

    @Override
    public void run() {
        add();
        System.out.println(i);
    }

    synchronized private void add() {
        i++;
    }
}

Main.java

package com.gacfox.demo;

public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread t1 = new Thread(myThread);
        Thread t2 = new Thread(myThread);
        Thread t3 = new Thread(myThread);

        t1.start();
        t2.start();
        t3.start();
    }
}

上述代码,我们让add()函数变为同步函数,这个函数在不同线程之间就只能串行执行了。两个线程都想要执行这个函数时,后一个线程必须等待前一个线程执行结束才能继续执行。实际上,线程同步在JVM底层是通过锁实现的,当线程执行到同步函数时,该线程获得对象锁,对象锁的锁定保证了同一对象上的其他线程只能等待不能执行。因此,如果把上述代码Main改为三个线程初始化为三个对象,实际上就并不会发生线程同步,因为锁定的是三个不同对象(但是访问的也是三个不同变量,倒也并不存在线程安全问题)。

注意:同步函数锁定的不是某段代码,而是对象。再考虑这样场景:当一个对象有两个同步函数时,由于锁定了对象,因此即使这两个函数操作的是不同变量,但是他们也不得不串行执行。

同步函数的常见问题说明

多次获得一把锁:同步函数的对象锁是可重入的,也就是说:当一个线程已经通过同步函数获得对象锁时,它再次申请同一个锁时会直接再次得到该对象的锁。说白了就是同步函数内调用同步函数是没有问题的。这很符合常理,否则同步函数调用同步函数就会死锁了,我们在实际开发中用到的锁基本都是可重入锁。

出现异常:一个线程出现异常时,如果它持有锁,锁会自动释放。

同步函数不能继承:子类不能继承父类函数的同步属性,因此如果子类也想让继承下来的函数同步,就必须重写该函数并加上synchronized关键字。

同步语句块

同步语句块比同步函数能够更加精细的控制锁和触发同步的位置。假如现在有这样一个函数:

synchronized private void add() throws InterruptedException {
    //假设只是一些耗时操作
    Thread.sleep(1000);
    //真正需要同步的操作
    i++;
}

由于上面代码实现的是同步函数,因此和变量访问不相关的耗时操作也必须排队执行,这显然不太符合逻辑,一种改进方法是分成两个函数,耗时操作异步,访问全局变量操作同步,这是可行的。但是使用同步语句块能更方便的解决这个问题:

private void add() throws InterruptedException {
    //假设只是一些耗时操作
    Thread.sleep(1000);
    synchronized(this) {
        //真正需要同步的操作
        i++;
    }
}

同步语句块和同步函数类似,都是由synchronized定义,this指定要锁定的对象,这里和同步函数一样锁定我们自定义的线程对象实例。同步块外的代码会并发执行,一旦进入同步块,就会试图获取对象锁,未能抢到锁的线程就要阻塞等待了。

同步语句块的常见问题说明

应该锁定谁:上面代码中,我们锁定了this,也就是自定义线程对象的实例。实际上,有些情况下我们不必这样做,有性能更好的解决方案:例如,有两个需要被多线程修改的对象ab,它们都在run()函数中,如果我们套上两个synchronized(this)语句块,那么修改a和修改b这两个不冲突的操作也是同步执行的,这就不如分别使用synchronized(a)synchronized(b),这样修改a和修改b时,获得的是不同对象的锁,因此就不会同步了,而修改a和另一个线程修改a则是同一把锁,是同步的。

对于锁定对象的要求:首先,锁定对象不能是基本类型。其次,最好使用final修饰。final表示该引用不可更改,实际上,引用如果被更改,其他线程获得的锁就不是原来对象的锁了,那样就做不到线程同步了。此外关于String对象,我们知道JVM对于String有常量池,也就是说new两个String对象,但是其值相同,那么获得的两个引用很有可能指向同一个对象实例,但也有可能不是。这也是为什么判断String相等必须使用.equals(),但是经常==结果也正确的原因。如果从String上获得锁,就要尤其注意这个问题,不过我们也很少这样做。

synchronized(MyThread.class):我在网上看到有些人在区分synchronized(this)synchronized(MyThread.class)。我们知道,Java中每个类都有Class对象,synchronized(MyThread.class)这种写法下同步块锁定的就是这个Class对象,其他没有什么特别的。不管锁定的是谁,它们都遵循统一的锁同步原理,在合适的场合下锁定合适的对象即可。除此之外,对static方法加上synchronized锁定的默认也是Class对象。

死锁

死锁是多线程程序中的一种常见BUG。假设我们有两个线程,线程1和线程2,两个锁a和b,线程1获得锁a同时,线程2获得锁b,两个线程并行执行一段时间,线程1未放开锁a同时要请求锁b,线程2未放开锁b同时要请求锁a,好了,死锁发生了。

MyThread.java

package com.gacfox.demo;

public class MyThread implements Runnable {
    boolean flag = true;

    @Override
    public void run() {
        if (flag) {
            synchronized (Main.lock1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (Main.lock2) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        } else {
            synchronized (Main.lock2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (Main.lock1) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

Main.java

package com.gacfox.demo;

public class Main {
    static final Object lock1 = new Object();
    static final Object lock2 = new Object();

    public static void main(String[] args) {
        MyThread myThread1 = new MyThread();
        myThread1.flag = true;
        MyThread myThread2 = new MyThread();
        myThread2.flag = false;
        Thread t1 = new Thread(myThread1);
        Thread t2 = new Thread(myThread2);
        t1.start();
        t2.start();
    }
}

死锁是程序的BUG,上面例子我们手动编写了一个死锁,但在实际开发中死锁问题可能十分隐蔽,死锁的发生是不确定的,造成程序一直阻塞也可能不是死锁。我们怎么定位程序是否死锁了呢?此时可以用JDK提供的jstack工具进行查看。

jstack <pid>

运行上面代码后再执行jstack工具,最终显示如图,果然发现了死锁。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap