Java多线程(四):线程安全问题

2021/7/3 20:51:49

本文主要是介绍Java多线程(四):线程安全问题,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

目录:

  • 1. 线程间的数据竞争
  • 2. synchronized 关键字
    • 2.1 synchronized 实现原理
    • 2.1 synchronized 方法锁、对象锁、类锁
  • 3. 锁(Lock)
    • 3.1 java.util.concurrent.locks.Lock 接口
    • 3.2 可重入
    • 3.3 可中断
    • 3.4 设置等待时间 & 公平锁
  • 4. 死锁
  • 5. 线程间通讯

1. 线程间的数据竞争

在使用多线程编程时,线程安全是我们必须要考虑的一个因素。对于线程安全,简单的来说,就是当一个变量被多个线程共享时,有可能会出现多个线程同时操作此变量的情况,这里就产生了竞争。竞争的结果就是变量最终的值不一定会是我们预期的值,可能在竞争的时候就已经“损坏”了。

举一个简单的例子:

public class ThreadSecurity implements Runnable {
    private int totalNumber = 0;

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            totalNumber++;
        }
    }

    public int getTotalNumber() {
        return totalNumber;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        ThreadSecurity count = new ThreadSecurity();
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(count);
            threads[i].start();
        }
        for (int i = 0; i < 10; i++) {
            try {
                // 等待所有线程执行完成
                threads[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 输出计数结果,预期值应该为:10000 * 10 = 100000
        System.out.println(count.getTotalNumber());
    }
}

程序运行结果如下:
在这里插入图片描述

程序最终并没有输出我们预期的结果100000,并且,如果尝试多次运行程序的话,每一次得到是输出几乎都是不同的(严格的说,应该都不大于100000)。

上述例子就是线程安全的一个典型例子。当有10个线程同时操作一个字段totalNumber时,线程间可能产生竞争,造成线程间数据不同步的问题。
数据不同步问题
入上图所示,由于可能存在线程间竞争,当线程A从主内存中取到totalNumber后,在其还未计算完毕并将计算结果同步回主内存时,线程B也从主内存中取了totalNumber,但此时totalNumber还是原来的值。也就是说,当线程A、B计算结束后,均会将X+1同步回主内存,即两次加1操作,最终totalNumber的值却只增加了1。

当然,可以利用volatile关键字保证主内存和工作内存中变量的一致性,但由于并不是所有的操作都是原子的,加上volatile关键字后还是可能会出现线程安全问题(可参考: volatile变量的线程安全问题)

关于主内存和工作内存,可参考: Java内存模型

2. synchronized 关键字

在JDK 1.5 之前,可以通过synchronized 关键字解决线程安全问题。

public class ThreadSecurity implements Runnable {
    private int totalNumber = 0;

	// 在方法上加synchronized关键字
    @Override
    public synchronized void run() {
        for (int i = 0; i < 10000; i++) {
            totalNumber++;
        }
    }
    
    /*
    public void run() {
    	// 利用synchronized同步块实现线程间同步
        synchronized (this) {
            for (int i = 0; i < 10000; i++) {
                totalNumber++;
            }
        }
    }
    */

    public int getTotalNumber() {
        return totalNumber;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        ThreadSecurity count = new ThreadSecurity();
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(count);
            threads[i].start();
        }
        for (int i = 0; i < 10; i++) {
            try {
                // 等待所有线程执行完成
                threads[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 输出计数结果,预期值应该为:10000 * 10 = 100000
        System.out.println(count.getTotalNumber());
    }
}

执行结果:
在这里插入图片描述

通过上述的两种synchronized的使用方法,很好的解决的线程间同步的问题,最终得到了预期的结果。

2.1 synchronized 实现原理

synchronized 通过一种“互斥同步”的机制来保证线程间的安全。 简单的来说,就是在操作共享变量之前,首先需要获得对应的锁(同一时刻,只能有一个线程持有该锁),只有拥有该锁的线程才能对变量进行操作。

synchronized原理
如上面的流程所示,synchronized关键字保证了同一时刻,只能有一个线程对共享变量进行操作,避免了数据竞争,实现了线程间的同步。

2.1 synchronized 方法锁、对象锁、类锁

如上面的例子所示,synchronized关键字有以下的使用方法:

  • 直接加在方法上:方法锁
  • 使用synchronized同步块:synchronized(object) { ... }
锁类型功能
方法锁每个带有synchronized关键字的实例方法都必须首先获得调用该方法的实例的锁,然后才能执行具体的方法;方法执行结束后,锁将被释放,期间其它线程无法再次获得该锁。
对象锁对象锁与方法锁类似,在进入synchronized同步块前需要获得括号中普通对象的锁(this代表本实例),执行结束后同样会释放锁。
类锁如果是静态方法上加synchronized关键字,或者synchronized同步块修饰的是Class对象,那么方法或同步块中的代码运行前就需要先获得该Class对象对应的锁(Class对象可以作为类的唯一标识符)。

3. 锁(Lock)

针对线程安全问题,JDK 1.5 为我们提供了一个新的更灵活的工具Lock,它的灵活性主要体现在以下方面:

  • 可重入
  • 提供了中断响应功能
  • 可设置申请锁时的等待时间
  • 可以提供公平锁

3.1 java.util.concurrent.locks.Lock 接口

Lock 接口规定的一个锁需要拥有的基本功能,其中定义了6个方法,如下所示:

public interface Lock {
    void lock(); // 尝试获得锁
    void lockInterruptibly() throws InterruptedException; // 尝试获得可中断锁
    boolean tryLock(); // 尝试获得锁,如获取失败则立刻返回false,并不会挂起等待
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 与tryLock()类似,但此方法可以设置等待时间
    void unlock(); // 释放锁
    Condition newCondition(); // 由于线程间通讯
}

Java 为我们 提供了一些 Lock 接口的默认实现:
在这里插入图片描述
从其命名中,我们便不难猜测它们各自的作用(读锁、写锁、可重入锁)。这里选择比较常见的可重入锁ReentrantLock(Reentrant,可重新进入)作为例子,以具体介绍锁的用法。

继续以上述的计数代码为例,这里将其改造为Lock的解决方法:

public class ThreadSecurity implements Runnable {
    private int totalNumber = 0;
    private static Lock countLock = new ReentrantLock();

    @Override
    public void run() {
        countLock.lock();
        for (int i = 0; i < 10000; i++) {
            totalNumber++;
        }
        countLock.unlock();
    }

    public int getTotalNumber() {
        return totalNumber;
    }

    public static void main(String[] args) throws ClassNotFoundException {
        Thread[] threads = new Thread[10];
        ThreadSecurity count = new ThreadSecurity();
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(count);
            threads[i].start();
        }
        for (int i = 0; i < 10; i++) {
            try {
                // 等待所有线程执行完成
                threads[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 输出计数结果,预期值应该为:10000 * 10 = 100000
        System.out.println(count.getTotalNumber());
    }
}

Lock的使用比较简单,首先我们需要实例化一个具体的实现了Lock接口的对象,然后就可以通过此对象进行加锁和释放锁。需要注意的是,这里的锁是需要我们手动释放的,也就是说,为了保证在获取了锁之后,能够安全的将其释放,最好countLock.unlock()操作放在finnally语句块中!

try {
	// 业务代码
} catch (Exception e) { // 可能会出现的异常
	// 异常处理
} finally {
	// 释放锁
	countLock.unlock();
}

3.2 可重入

当然,正如它的名字所示,ReentrantLock是可重入的(就如字面意思所表达的,即可以多次进入):

countLock.lock();
countLock.lock(); // 重入
countLock.lock();

// 重入了几次锁,就需要释放几次
countLock.unlock();
countLock.unlock();
countLock.unlock();

这里需要注意的是,如果我们多次进入了同一个锁,就需要相应的释放多少次! 只有这样,这个锁才有可能重新被其他线程所获得。

为帮助理解,可以想象锁中存在一个重入的计数器,每次lock()都会让此计数器加1,unlock()操作都会让其减1;只有当该计数器的值减少到0的时候,此锁才是空闲的,才可以被其它线程获得。

3.3 可中断

对于关键字synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,则提供另外一种可能,正如 Lock 接口所定义的,它给我们提供了中断处理的功能,通过lockInterruptibly()方法可以获得一个可以响应中断的锁。

public class LockInterrupt implements Runnable {
    private static Lock lock = new ReentrantLock();

    @Override
    public void run() {
        lock.lock();

        try {
            String time = new SimpleDateFormat("HH:mm:ss").format(new Date());
            System.out.println(time + ":" +Thread.currentThread().getName() + " Enter");
            Thread.sleep(9000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        LockInterrupt runnable = new LockInterrupt();
        Thread thread1 = new Thread(runnable, "Thread1");
        Thread thread2 = new Thread(runnable, "Thread2");

        System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date()));
        thread1.start();
        Thread.sleep(1000);
        thread2.start();

        Thread.sleep(3000);

        thread1.interrupt();
    }
}

执行结果:
在这里插入图片描述
通过Lock提供的中断响应功能,可以更灵活的控制锁。例如,如果程序遇到了死锁,那么利用中断就可以很轻松的解决它。

3.4 设置等待时间 & 公平锁

如果申请锁的时候,发现锁当前已被另外一个线程占用,而申请锁的**线程又不想一直等待下去,而是想利用等待的时间去干一些其它的事情。**这种情况下,就可以通过设置等待时间来实现这个功能。

通过Lock接口定义的tryLock()方法,就可以设置等待时间。

// 申请获得锁,如果未成功,则放弃并返回false;
lock.tryLock();

// 从捕获的异常就可以看出,等待的过程是可以被中断的
try {
	// 申请获得锁,如果5s内未成功,则放弃并返回false;
    boolean flag = lock.tryLock(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    e.printStackTrace();
}

**在大多数的情况下,锁的申请都是不公平的。**也就是说,线程A首先申请了锁,接着线程B也申请了同一个锁,当锁可用的时候,并不能根据申请的顺序判断谁会得到锁。也就是说,先申请的不一定先获得锁。

当然,如果想要获得一个公平的锁,Lock也为我们提供的响应的实现机制。查看ReentrantLock的源码,可用看到其中有这样一个构造方法:

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

参数fair由于表示此锁是否为一个公平锁:

  • true:公平
  • false:不公平

需要注意的是,除非是十分必要的场合,否则尽量不要使用公平锁!

原因很简单:要实现公平锁就要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能却非常低下。

4. 死锁

虽然Java为我们提供了十分遍历的线程同步机制,但在使用过程中还是有一些需要注意的地方,死锁就是其中一个比较麻烦的地方。顾名思义,死锁了意思就是由于线程间一些特定的操作,导致某些锁永远也不可能再被获得。

造成死锁的情况有很多,可以简单的这样理解死锁产生的过程:

  1. 线程A、B的工作都需要两个锁lock1、lock2的支持;
  2. 线程A首先获得了lock1,开始了它的工作;
  3. 后面线程B也开始工作,不同的是,线程B首先需要获得lock2;
  4. A工作到一半,发现需要lock2了,这时候它就去申请lock2,但此时lock已被B持有,因此A挂起等待(并没有释放lock1);
  5. 同样的,当B需要lock1的时候,B也去申请lock1,但此时lock1还被A持有着,因此B也挂起等待(并没有释放lock2);
  6. 至此,死锁就产生了,正常情况下lock1和lock2永远不会被释放(可用中断打破死锁)。

转换成代码,此过程如下所示:

public class Deadlock {
    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        // 线程1:先获取lock1,再获取lock2
        Thread thread1 = new Thread(() -> {
            try {
                lock1.lockInterruptibly();
                Thread.sleep(8000);
                lock2.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "成功获得lock2");
            } catch (InterruptedException e) {
                // 处理中断
            } finally {
                if (lock1.isHeldByCurrentThread()) {
                    lock1.unlock();
                }
                if (lock2.isHeldByCurrentThread()) {
                    lock2.unlock();
                }
                System.out.println(Thread.currentThread().getName() + "End");
            }
        }, "Thread1");
        thread1.start();

        // 线程2:先获取lock2,再获取lock1
        Thread thread2 = new Thread(() -> {
            try {
                lock2.lockInterruptibly();
                Thread.sleep(4000);
                lock1.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "成功获得lock1");
            } catch (InterruptedException e) {
                // 处理中断
            } finally {
                if (lock1.isHeldByCurrentThread()) {
                    lock1.unlock();
                }
                if (lock2.isHeldByCurrentThread()) {
                    lock2.unlock();
                }
                System.out.println(Thread.currentThread().getName() + "End");
            }
        }, "Thread2");
        thread2.start();

        // 可以利用中断,解决死锁问题
//        Thread.sleep(10000);
//        thread1.interrupt();
    }
}

运行上述代码会发现,程序一直在运行,无法正常打印结果并结束,这里可以通过中断解决此死锁(当然,通过前面提及的设置等待时间的方法也可以解决死锁问题)。

当然,在分配锁的时候,我们就应当考虑到死锁问题,尽可能的避免死锁的产生。

5. 线程间通讯

synchronizedLock 都有提供相应的线程间通讯机制,具体可以参考: 线程间通讯



这篇关于Java多线程(四):线程安全问题的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程