背景

若要实现高并发,必须保证线程安全。

一、线程共享数据与线程安全

1.数据不共享

由于java为每个对象创建各自的局部变量和空间,所以为了保证数据安全,自定义的线程类使用的资源数据不是同一个对象的即可。

//线程
public class MyThread extends Thread{
    private int count = 5;
    public MyThread(String name){
        super();
        this.setName(name);
    }
    @Override
    public synchronized void run() {
        super.run();
        while (count>0){
            count--;
            System.out.println("新线程名字:"+Thread.currentThread().getName()+" id="+Thread.currentThread().getId()+" count的值:"+count);
        }
    }
}
//运行
public class Run{
    public static void main(String[] args) {
        //各个线程类拥有各自的资源数据
        MyThread a = new MyThread("A");
        MyThread b = new MyThread("B");
        MyThread c = new MyThread("C");
        a.start();
        b.start();
        c.start();
    }
}
/*
新线程名字:A id=12 count的值:4
新线程名字:C id=14 count的值:4
新线程名字:B id=13 count的值:4
新线程名字:C id=14 count的值:3
新线程名字:A id=12 count的值:3
新线程名字:C id=14 count的值:2
新线程名字:B id=13 count的值:3
新线程名字:C id=14 count的值:1
新线程名字:A id=12 count的值:2
新线程名字:C id=14 count的值:0
新线程名字:B id=13 count的值:2
新线程名字:B id=13 count的值:1
新线程名字:A id=12 count的值:1
新线程名字:B id=13 count的值:0
新线程名字:A id=12 count的值:0
*/

2.数据共享

//线程
public class MyThread extends Thread{
    private int count = 5;
    @Override
    public void run() {
        super.run();        
        count--;
        System.out.println("新线程名字:"+Thread.currentThread().getName()+" id="+Thread.currentThread().getId()+" count的值:"+count);
        
    }
}
//
//运行
public class Run{
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        //各个线程类拥有同一对象target
        Thread a = new Thread(myThread, "A");
        Thread b = new Thread(myThread, "B");
        Thread c = new Thread(myThread, "C");
        Thread d = new Thread(myThread, "D");
        Thread e = new Thread(myThread, "E");
        a.start();
        b.start();
        c.start();
        d.start();
        e.start();
    }
}
/*
新线程名字:A id=13 count的值:3
新线程名字:C id=15 count的值:2
新线程名字:D id=16 count的值:1
新线程名字:E id=17 count的值:0
新线程名字:B id=14 count的值:3
*/

3.线程安全

上面多个线程同时对count进行处理产生冲突,产生了“非线程安全”问题。

public class MyThread extends Thread{
    private int count = 5;
    //加关键字
    @Override
    public synchronized void run() {
        super.run();        
        count--;
        System.out.println("新线程名字:"+Thread.currentThread().getName()+" id="+Thread.currentThread().getId()+" count的值:"+count);        
    }
}
/*
新线程名字:A id=13 count的值:4
新线程名字:D id=16 count的值:3
新线程名字:E id=17 count的值:2
新线程名字:C id=15 count的值:1
新线程名字:B id=14 count的值:0
*/

加上synchronized关键字上锁,使线程排队操作,可以做到线程安全。

二、线程同步的问题

当两个或以上的线程需要共享对同一数据的存取,并且每一个线程都调用了一个修改该对象状态的方法。就存在由于交叉操作而损坏数据的可能性。这种潜在的交叉操作术语上称为临界区(critical region)。

保护代码块受并发访问干扰的机制

1.锁

使用可重入锁ReentrantLock保护代码块,基本结构如下:

private Lock myLock = new ReentrantLock();
myLock.lock();//上锁
try
{
    //临界区
}
finally
{
    mylock.unlock();//确保即使临界区抛出异常也必须解开锁,否则,其它线程永远阻塞
}

同步与非同步的比较
代码测试:

public class ThreadTest {
    public static final int account_number = 100;//100个账户
    public static final double init_balance = 1000;//初始余额
    public static final double max_amount = 1000;//最大转账金额
    public static final int delay = 10;//延迟

    public static void main(String[] args) {
        //初始化100个账户,每个账户1000元
        Bank bank = new Bank(account_number,init_balance);
        //遍历100个账户随机向其它账户转移随机金额
        for (int i =0;i<account_number;i++){
            int fromAccount = i;
            Runnable r = () ->{
                try{
                    while (true){
                        int toAccount = (int) (bank.size()*Math.random());
                        double amount = max_amount*Math.random();
                        bank.transfer(fromAccount,toAccount,amount);
                        Thread.sleep((long) (delay*Math.random()));
                    }
                }catch (InterruptedException e){
                }
            };
            Thread t = new Thread(r);
            t.start();
        }
    }
}
public class Bank {
    public final double[] accounts;
    //初始化银行类
    public Bank(int n ,double initialBalance) {
        accounts = new double[n];
        Arrays.fill(accounts,initialBalance);
    }

    /**
     *
     * @param from 转账的来源账户
     * @param to 转账的目标账户
     * @param amount 转移的金额
     */
    public void transfer(int from, int to, double amount){
        //余额不足,则什么也不做
        if (accounts[from]<amount) {
            return;
        };
        System.out.println(Thread.currentThread());
        //扣款
        accounts[from] -= amount;
        System.out.printf("%10.2f 从账户 %d 到账户 %d",amount,from,to);
        //收款
        accounts[to] += amount;
        System.out.printf(" 总金额: %10.2f%n",getTotalBalance());

    }
    //计算银行总金额
    public double getTotalBalance(){
        double sum = 0;
        for (double a :accounts){
            sum += a;
        }
        return sum;
    }
    //获得银行账户数量
    public int size(){
        return accounts.length;
    }
}

上述代码,意为随机账户向随机账户转账随机的金额,该操作会一直运转下去,只有CTRL+C或其它方法才能终止程序。测试发现,一段时间过后总金额发生了错误。

可见,线程发生了冲突。为了避免这种情况,我们将上述转账操作上锁放在临界区里。

private final Lock bankLock = new ReentrantLock();
public void transfer(int from, int to, double amount){
    bankLock.lock();
    try{
        //余额不足,则什么也不做
        if (accounts[from]<amount) {
            return;
        };
        System.out.println(Thread.currentThread());
        accounts[from] -= amount;
        System.out.printf("%10.2f 从账户 %d 到账户 %d",amount,from,to);
        accounts[to] += amount;
        System.out.printf(" 总金额: %10.2f%n",getTotalBalance());
    }
    finally {
        bankLock.unlock();
    }
}

程序继续一直运转下去,测试结果:

可见,将代码上锁放在临界区,是可靠的。

代码解释:

每个Bank对象都拥有自己的ReentrantLock锁对象,拥有自己的资源accounts,当进行敏感操作transfer时,代表要操作自己的资源accounts,而这很可能引起冲突,所以在transfer方法体内立即上锁bankLock.lock()。

此时当一个线程操作Bank对象时,阻塞其它线程对该Bank对象的操作,保证安全。

而如果两个线程访问不同的Bank对象(比如new了两个Bank对象),则代表访问不同的资源,这种情况是并行的,不会发生阻塞。

ReentrantLock是可重入的,意思是某个已经持有锁的线程可再次嵌套调用被该锁保护的代码,而不会发生死锁。

例如,transfer方法体内对bankLock对象的持有计数是1,若嵌套调用getTotalBalance方法,持有计数变成2,当退出getTotalBalance方法时,持有计数变成1,当继续退出transfer方法时,持有计数变为0;这时共享对象才可被其它线程访问。

而不可重入锁,意思是持有锁的线程无法再次调用其它拥有该锁的方法,因为被阻塞了。

认识条件对象(条件变量)

通常,线程进入临界区,却发现在某一条件满足之后才能执行。

例如,上述银行转账操作,余额大于等于转账金额才能转账。

if (accounts[from]<amount) {
    return;
};

我们需要将线程进入等待状态,而不是直接结束线程。这时就需要条件对象(条件变量Condition)进行线程间的通信。

在JDK5之前,是通过Object对象的**(wait、notify 和 notifyAll)方法进行线程间通信,现在Condition(await、signal和signalAll)**代替了Object监视器方法的使用。

public class Bank {
    public final double[] accounts;
    private final Lock bankLock;
    private final Condition sufficientFunds;
    //初始化银行类
    public Bank(int n ,double initialBalance) {
        accounts = new double[n];
        Arrays.fill(accounts,initialBalance);
        //创建可重入锁
        bankLock = new ReentrantLock();
        //创建条件对象
        sufficientFunds = bankLock.newCondition();
    }

    /**
     *
     * @param from 转账的来源账户
     * @param to 转账的目标账户
     * @param amount 转移的金额
     */
    public void transfer(int from, int to, double amount){
        //加锁
        bankLock.lock();
        try{
            while (accounts[from]<amount) {
                //余额不足时,线程进入等待队列,自动释放锁,让位给其它线程给账户存款
                //需要其它线程唤醒
                sufficientFunds.await();
            };
            System.out.println(Thread.currentThread());
            //扣款
            accounts[from] -= amount;
            System.out.printf("%10.2f 从账户 %d 到账户 %d",amount,from,to);
            //存款
            accounts[to] += amount;
            System.out.printf(" 总金额: %10.2f%n",getTotalBalance());            
            //唤醒所有等待队列中满足sufficientFunds条件的线程,进入可运行状态
            sufficientFunds.signalAll();
        }finally{
            bankLock.unlock();
        }
    }
    //计算银行总金额
    public double getTotalBalance(){
        bankLock.lock();
        try{
            double sum = 0;
            for (double a :accounts){
                sum += a;
            }
            return sum;
        }finally{
            bankLock.unlock();
        }

    }
    //获得银行账户数量
    public int size(){
        return accounts.length;
    }
}

代码解释:

条件对象中条件的定义处在如下形式:

while(!(ok to proceed))
    condition.await();

为什么用while而不用if?

因为处在等待状态的线程可能被虚假唤醒,然后所有满足该条件的线程重新竞争锁,并且需要被分配到时间片执行,没有竞争到的线程需要重新回到等待状态。而如果使用if,所有线程判断一次条件就都运行下去了,而在此过程条件可能被其它线程所改变,出现未知错误。

何时唤醒等待队列的线程?

当对象的状态有利于等待线程的方向改变时调用signalAll方法。例如,转账完成时,余额不足的账户可能余额变成充足,此时可以检查条件。

signalAll只是解除等待线程的阻塞,线程依然需要竞争对对象资源的访问。

signal是随机解除等待队列中某个线程的阻塞状态。

2.synchronized关键字

Lock和Condition接口提供了高度的锁定控制,然而大多数并不需要那样的控制。java中每一个对象都有一个内部锁和一个内部条件,如果方法用synchronized关键字声明,那么对象的锁保护整个方法。

public synchronized void method()
{
    method body
}
//等价于
public void method()
{
    this.intrinsicLock.lock();
    try
    {
        method body
    }
    finally{this.intrinsicLock.unlock();}
}

有了synchronized关键字之后,所以Bank类的transfer方法可以简单的声明为synchronized,而不是使用一个显式的锁。

public class Bank
{
    private double[] accounts;
    public synchronized void transfer(int from, int to, int amount) throws InterruptedException
    {
        while(accounts[from] < amount)
            wait();//使线程进入等待状态直到被通知,该方法只能在同步方法中调用
        accounts[from] -= amount;
        accounts[to] += amount;
        notifyAll();//解除该对象上调用wait方法的线程的阻塞状态,该方法法只能在同步方法中调用
    }
    //省略其它代码
}

为方便的调用其它对象的内部锁,synchronized还有如下形式的用法

synchronized (obj)
{
    //临界区
}

根据每一个对象有一个内部锁,可以创建一个同步阻塞来控制线程间的互斥与并发。

public class SeparateGroups 
{
    private double aVal = 0.0;
    private double bVal = 1.1;
    protected Object lockA = new Object();//创建对象构造同步阻塞
    protected Object lockB = new Object();//创建另一个对象构造同步阻塞
    
    //aVal的读方法和写方法互斥,同步阻塞
    public double getA(){
        synchronized(lockA){
            return aVal;
        }
    }
    public double setA(double val){
        synchronized(lockA){
            aVal = val;
        }
    }
    //bVal的读方法和写方法互斥,同步阻塞
    public double getB(){
        synchronized(lockB){
            return bVal;
        }
    }
    public double setB(double val){
        synchronized(lockB){
            bVal = val;
        }
    }
    //但是aVal和bVal可以并发运行,因为它们使用不同对象的锁,在不同的同步阻塞中
    
    //获得两个锁,进行同步设置
    public void reset(){
        synchronized(lockA){
            synchronized(lockB){
                aVal = bVal =0.0;
            }
        }
    }    
}

synchronized的使用技巧:

synchronized语句可以定义比方法小的同步代码,同步语句应旨在完全必要的时候才使用锁。比如,进行复杂计算并将结果赋给某个域的方法,应该只保护实际的域赋值,而不是计算过程

3.volatile域

有时仅仅为了读写一两个实例域就使用同步,未免开销太大。volatile是一种轻量级的同步方式。

目前普通的实例域面临的问题:

  • 使用现代的多处理器计算机,会将内存中的值缓存到寄存器以加快程序运行,而此过程中内存中的值可能发生改变,导致多个CPU寄存器取到了不同的值。
  • 而编译器会对代码进行重排序,以达到更大的吞吐量。而此过程中,实例域的值对其它线程不可见。

使用volatile保护域之后,可以让cpu每次在使用域之前,再次在内存中获得值,而不是从寄存器缓存中,也禁止编译器对该域进行重排序。

private boolean done;
public synchronized boolean isDone(){return done;}
public synchronized void setDone(){done = true;}
//等价于
private volatile boolean done;
public boolean isDone(){return done;}
public void setDone(){done = true;}

volatile不保证原子性,仅在满足以下条件的场景中使用:

  • 运算结果并不依赖变量的当前值,或者能够只有单一线程能修改变量的值
  • 变量不需要与其它的状态变量共同参与不变约束。
//代码示例,以下代码修改实例域的值,并不依赖之前的值,所以可以用volatile
private volatile boolean shutdownTag;
public void shutdown(){
    shutdownTag = true;
}
public void doWork(){
    while(!shutdownTag){
        //工作代码
    }
}
//代码示例,以下代码依赖实例域之前的值,所以不适合用volatile,应该用synchronized上锁,尽管开销大一些
private volatile boolean shutdownTag;
private volatile int sum = 0;
public void reverse(){
    shutdownTag = !shutdownTag;
}
public void increase(){
    sum++;
}

4.final关键字

被final修饰的字段一旦在构造器初始化完成,并且构造器没有将“this“引用传递出去,那在其它线程就能看见final字段的值。

5.原子性