首页 > 技术文章 > Java线程安全

zhya 2018-09-11 16:04 原文

Java线程安全

使用多线程可以在一定程度上提高程序性能(并非绝对),但随之而来的线程安全问题却无法忽略,了解线程安全的基本概念和常用解决方式,以及jdk提供的并发包,会帮助日常工作中更好的发现和处理线程安全问题。

并发和并行

并行指的是多个线程或者进程在同一时刻同时执行;

并发则是由cpu调度,交替执行。

单核cpu一个时刻只能执行一条指令,在单核cpu上的多线程是由cpu调度交替执行,叫做并发;如果是多核cpu,则每个cpu都可以执行指令,每个cpu上的指令有可能同时执行,叫做并行。

什么是线程安全问题

多线程操纵临界区即共享资源、共享变量时,可能会产生不符合预期的结果,即线程安全问题。

/**
 * 线程安全问题测试类
 **/
public class ThreadSafeIssueTest {
    /**
     * 余额
     */
    public static Integer REMAIN_MONEY = 10000;

    public static void main(String[] args) {

        // 模拟并发取钱问题
        for (int i = 0; i < 20; i++) {
            new Thread(new Test1ThreadWrite(1000)).start();
        }

        // 等待取钱线程结束
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("remain money : " + REMAIN_MONEY);
    }
}

/**
 * 模拟并发取钱问题
 **/
class Test1ThreadWrite implements Runnable {
    /**
     * 要取的钱
     */
    private int detectMoney;

    public Test1ThreadWrite(int detectMoney) {
        this.detectMoney = detectMoney;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if (ThreadSafeIssueTest.REMAIN_MONEY >= detectMoney) {
            ThreadSafeIssueTest.REMAIN_MONEY -= detectMoney;
            System.out.println(Thread.currentThread().getId() + " detected 1000");
        }
    }
}

虽然在代码中做了当前余额>=取现金额,才进行余额的扣减,但是运行结果可能会输出取现的钱超过余额总额。

线程内存模型 

如上图所示,Java的内存模型中有主存和线程工作内存,多线程的工作内存是独立的、不可见的。线程运行时从主从复制共享变量并缓存到工作内存中,修改完成后再回写到主存。由于多线程工作内存是独立的,线程A对共享变量的修改,线程B无法及时知晓,如上面的例子中一个线程将余额由1000扣减成了0,但是还未写入到主存中,这是另一个线程读取到的余额还是1000,依然满足扣减条件,造成重复扣减(数据不正确)。

如何解决线程安全问题

保证可见性 

 volatile关键字

 使用volatile修饰共享变量,可以保证共享变量的可见性(可以看成线程工作内存不再缓存共享变量,而是直接操纵主存中的),但是上例中使用volatile修饰余额变量后,就不会有问题了吗? 答案是否定的,因为对余额的操作分为两步(比较,赋值),并非原子操作,多线程并行情况下,两个线程可能同时读取到当前余额是1000。

阻塞方式(串行)

“我觉得多个线程操纵临界区,肯定会把临界区数据改坏掉”

synchronized关键字

Lock

显式的加锁和sychronized差不多(ReentryLock可重入,即可中断,而sychronized不可以)

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 锁测试
 **/
public class LockTest {
    public static void main(String[] args) {
        LockTestThread testThread = new LockTestThread();
        for (int i = 0; i < 50; i++) {
            new Thread(testThread).start();
        }
    }
}

class LockTestThread implements Runnable {
    /**
     * 定义一把锁
     */
    private Lock lock = new ReentrantLock();

    /**
     * 共享变量
     */
    private int num = 0;

    @Override
    public void run() {
        lock.lock();
        try {
            System.out.println(++num);
        } finally {
            lock.unlock();
        }
    }
}

 

阻塞方式的问题:同一时刻只有一个线程可以获得临界区的锁,其他线程将被挂起,等待cpu再次调度执行(再次执行时如果未获得锁则继续被挂起)。一方面cpu再次调度执行被挂起的线程需要花费一定的时间,造成时间上的浪费;另一方面每个被挂起的线程都占用一定的内存,而被挂起时不会释放,造成资源浪费。

非阻塞方式

“我觉得多个线程操纵临界区,不一定会把临界区数据改坏掉” 

读写锁

读写操作加不同的锁,读读不阻塞,读写阻塞。

参考ReadWriteLock。

copy-on-write

写时复制,读写不阻塞,写的时候将原有数据复制一份出来进行修改,然后再赋值给原变量。

参考CopyOnWriteList。

cas算法

参考下面的jdk提供的原子类。

ThreadLocal

 线程本地变量,每个线程有一份,互不干扰,没有操纵临界区,也就没有线程安全问题。

Jdk并发包(JUC)

ConcurrentMap

 分段方式提高并发度。多线程操纵临界区,同时抢占一把锁,而只有一个线程能占有锁,其他线程等待,造成资源浪费;而一个锁分成多个锁,即把临界区数据分段,每个数据段上加锁,那么多线程时,线程A如果需要操纵数据段1上的数据则去竞争数据段1上的锁,线程B需要操纵数据段2上的数据则去竞争数据段2上的锁,互不干扰,线程A和B之间无需等待。这样的话,一个大的数据可以被多个线程同时操纵,并且不会有线程安全问题,提高了效率,这也就是ConcurrentHashMap的原理。

ForkJoin

 一个大的任务,比如计算1累加到100亿,使用单线程执行的话耗费的时间比较多,效率低;但是使用多线程分段计算的话就需要手动收集每个线程的计算结果并汇总,比较麻烦,难控制。ForkJoin就是为了解决这一问题,通过递归的将大的任务fork成一个个小任务,小任务完成后通过join返回结果并汇总。

参考嘟嘟商户平台项目中的FileRpc类。

原子类

 jdk提供了利用底层的cas算法(原子操作)实现的原子类,如AtomicInteger、AtomicBoolean等,多线程环境下对原子类的操作是非阻塞的并且是线程安全的。

cas算法即compare-and-swap,先比较再替换,并且是原子操作。

原子类中提供compareAndSet方法,except参数代表当前的期望值,update参数代表要更新的目标值;如果当前值和except相同则更新成功;否则更新失败,可以选择重试或者其他失败处理方式,不会阻塞。

 

推荐阅读