Java并发编程学习(2):synchronized的使用与线程安全类

问题引入

在下面的代码中,两个线程操作了同一个变量count,其中一个线程执行自增,另一个线程执行自减,且各自均执行5000次。直观感受上,变量count的最终结果应该为0,但事实并非如此。

static Integer count = 0;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            count++;
        }
        log.info("结束");
    }, "t1");
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            count--;
        }
        log.info("结束");
    }, "t2");
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.info("count: {}",count);
}

代码执行结果如下,最终结果距离0相去甚远。

[161 ms] [INFO][t2] i.k.e.c.e.SelfIncreasingNotSaveDemo : 结束
[161 ms] [INFO][t1] i.k.e.c.e.SelfIncreasingNotSaveDemo : 结束
[167 ms] [INFO][main] i.k.e.c.e.SelfIncreasingNotSaveDemo : count: -2290

分析原因

这是因为对于静态变量而言,操作符++--都不是原子操作,它们都由4个指令组成。
其中,i++的指令为

getstatic i   // 获取静态变量i的值
iconst_1      // 准备常量1
iadd          // 自增
putstatic i   // 将修改后的值存入静态变量i

类似地,i--的指令为

getstatic i   // 获取静态变量i的值
iconst_1      // 准备常量1
isub          // 自减
putstatic i   // 将修改后的值存入静态变量i

再多线程下,这些指令的执行可能会出现交错的情况,例如:

线程1 线程2 static i getstatic i 读取 0 iconst_1 准备常数 1 isub 减法, 线程内 i = -1 上下文切换 getstatic i 读取 0 iconst_1 准备常数 1 iadd 加法, 线程内 i = 1 putstatic i 写入 1 上下文切换 putstatic i 写入 -1 线程1 线程2 static i

此时,虽然两个线程均执行完一轮,但是静态变量i的最终结果并不等于0。

临界区

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

竞态条件

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 非阻塞式的解决方案:原子变量
  • 阻塞式的解决方案:synchronized、Lock

原子变量

原子变量类的命名类似于AtomicXxx,例如,AtomicInteger类用于表示一个int变量。
原子变量可用于在不使用任何锁的情况下以原子方式对单个变量执行多个指令。
原子变量可以分为以下几类:

  • 标量原子变量类
    AtomicInteger,AtomicLong和AtomicBoolean类分别支持对原始数据类型int,long和boolean的操作。
    当引用变量需要以原子方式更新时,AtomicReference类用于处理引用数据类型。
  • 原子数组类
    有三个类称为AtomicIntegerArray,AtomicLongArray和AtomicReferenceArray,它们表示一个int,long和引用类型的数组,其元素可以进行原子性更新。
  • 原子字段更新程序类
    有三个类称为AtomicLongFieldUpdater,AtomicIntegerFieldUpdater和AtomicReferenceFieldUpdater,可用于使用反射以原子方式更新类的易失性字段。要获得对这些类的对象的引用,您需要使用他们的工厂方法newUpdater()。
  • 原子复合变量类

代码示例
上面的代码可以使用原子变量改写为以下形式

static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            count.incrementAndGet();
        }
        log.info("结束");
    }, "t1");
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            count.decrementAndGet();
        }
        log.info("结束");
    }, "t2");
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.info("count: {}",count);
}

synchronized

synchronized,俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程再想获取这个对象锁时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
注意:虽然Java中同步和互斥都可以使用synchronized关键字来完成,但是它们是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

synchronized实际是使用对象锁的方式保证了临界区代码的原子性,临界区内的代码堆外是不可分割的,不会被线程切换所打断。
代码示例
上面的代码可以使用synchronized关键字改写为以下形式

static Integer count = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (obj) {
                count++;
            }
        }
        log.info("结束");
    }, "t1");
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (obj) {
                count--;
            }
        }
        log.info("结束");
    }, "t2");
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.info("count: {}",count);
}

注意:不能直接使用count加锁,因为静态变量count是一个不可变对象,每次对他进行修改都会产生一个新的对象,则会导致两个线程可能使用不同的锁,进而导致加锁失败。

线程1 自增 线程2 自减 static i i = 0 竞争锁0 拿到锁0 getstatic i 读取 0 iconst_1 准备常数 1 isub 减法, 线程内 i = -1 上下文切换 竞争锁0 陷入阻塞,等待锁0的释放 上下文切换 putstatic i 写入 -1 i = -1 释放锁0 上下文切换 拿到锁0 getstatic i 读取 -1 iconst_1 准备常数 1 iadd 加法, 线程内 i = 0 上下文切换 竞争锁-1 拿到锁-1 getstatic i 读取 -1 iconst_1 准备常数 1 isub 减法, 线程内 i = -2 putstatic i 写入 -2 i = -2 释放锁-1 上下文切换 putstatic i 写入 0 i = 0 释放锁0 线程1 自增 线程2 自减 static i

在上面的例子中,一共执行了2次减法和1次加法,而且也加了锁,但是最终的结果并非-1,这就是因为线程1与线程2竞争的锁并不相同,两线程的指令出现了交叉执行。

使用面向对象的方式改造代码

public class SelfIncreasingNotSafeDemo {
    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                room.increment();
            }
            log.info("结束");
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                room.decrement();
            }
            log.info("结束");
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info("count: {}",room.getCount());
    }
}

class Room{
    private int count = 0;
    public void increment(){
        synchronized (this){
            count ++;
        }
    }
    public void decrement(){
        synchronized (this){
            count --;
        }
    }

    public int getCount() {
        synchronized (this){
            return count;
        }
    }
}

在方法上使用synchronized

加在成员方法(普通方法)

加在成员方法上相当于锁住this对象

public class SynchronizedDemo {
    public synchronized void fun(){
        // TODO
    }
}

等价于

public class SynchronizedDemo {
    public void fun(){
        synchronized (this){
            // TODO
        }
    }
}

加在静态方法上

加在静态方法上相当于锁住类对象

public class SynchronizedDemo {
    public static synchronized void fun(){
        // TODO
    }
}

相当于

public class SynchronizedDemo {
    public static void fun(){
        synchronized (SynchronizedDemo.class){
            // TODO
        }
    }
}

变量的线程安全分析

成员变量与静态变量

  • 如果它没有被共享,则线程安全
  • 如果它被共享,则依据状态是否能够改变,又分为两种情况
    • 如果只有读操作,则线程安全
    • 如果含有写操作,则这段代码是临界区,需要考虑线程安全

局部变量

  • 局部变量是线程安全的
  • 但是局部变量应用的对象则未必
    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离了方法的作用范围,需要考虑线程安全

局部变量逃逸的案例

public abstract class Test {
	public void bar() {
		// 是否安全
		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
		foo(sdf);
	}
	public abstract foo(SimpleDateFormat sdf);
	public static void main(String[] args) {
		new Test().bar();
	}
}

其中 foo 的行为是不确定的,可能导致不安全的发生,被称之为外星方法

public void foo(SimpleDateFormat sdf) {
	String dateStr = "1999-10-11 00:00:00";
	for (int i = 0; i < 20; i++) {
		new Thread(() -> {
			try {
			sdf.parse(dateStr);
			} catch (ParseException e) {
			e.printStackTrace();
			}
		}).start();
	}
}

线程安全类

线程安全类是指,多个线程调用它们同一个实例的某一个方法时,是线程安全的。也可以理解为

  • 它们的每个方法都是原子的
  • 但是它们多个方法的组合不是原子的

常见的线程安全类有:

  • String
  • StringBuffer
    • StringBuilder是线程不安全的
  • Integer等包装类
  • Random
  • Vector(效率很低)
  • HashTable(效率很低)
  • java.util.concurrent包下的类

不可变类的线程安全性

StringInteger等都是不可变类,其内部的状态是不可以改变的,因此他们了都是线程安全的。

习题

售票问题

编写一个售票窗口类TicketWindow类,内部有一个成员变量count记录当前的余票数量。
TicketWindow类有以下方法需要实现

方法返回值类型功能
TicketWindow(int count)-类构造方法,需要传入总余票数量
getCount()int获取当前余票数量
sell(int amount)int售票操作,传入需求量,如果需求量不超过余票量,则返回需求量,同时余票数量相应减少,否则直接返回0

测试类

测试类已经完善,需要最终的输出的数据正常

public class SellTicket {
    public static void main(String[] args) throws InterruptedException {
        int count = 10000;
        TicketWindow ticketWindow = new TicketWindow(count);
        List<Thread> threads = new ArrayList<>();
        // 需要保证amounts的线程安全(仅针对add()方法)
        List<Integer> amounts = Collections.synchronizedList(new ArrayList<>());
        // Random是线程安全的
        Random random = new Random();
        // 创建任务,向售票窗口买票,并统计买票数量
        Runnable task = () -> {
             try {
                Thread.sleep(random.nextInt(20));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            int amount = ticketWindow.sell(random.nextInt(5) + 1);
            amounts.add(amount);

        };
        // 创建线程
        for (int i = 0; i < 2500; i++) {
            Thread thread = new Thread(task, "T-" + i);
            threads.add(thread);
        }
        log.info("开始执行");
        // 执行线程
        for (Thread thread : threads) {
            thread.start();
        }
        // 等待所有线程执行完毕
        for (Thread thread : threads) {
            thread.join();
        }
        // 统计一共买到的票数
        int totalAmount = amounts.stream().mapToInt(i -> i).sum();
        log.info("总票数:{}",count);
        log.info("一共售出:{}",totalAmount);
        log.info("余票数:{}",ticketWindow.getCount());
        log.info(totalAmount+ticketWindow.getCount()==count?"数据正常":"数据异常");
    }
}

线程不安全的代码

class TicketWindow{
    private int count;

    public TicketWindow(int count) {
        this.count = count;
    }

    public int getCount() {
        return count;
    }

    public int sell(int amount){
        if (count >= amount){
            count -= amount;
            return amount;
        }else {
            return 0;
        }
    }
}

输出结果

[167 ms] [INFO][main] i.k.e.c.h.SellTicket : 开始执行
[372 ms] [INFO][main] i.k.e.c.h.SellTicket : 总票数:10000
[373 ms] [INFO][main] i.k.e.c.h.SellTicket : 一共售出:7631
[373 ms] [INFO][main] i.k.e.c.h.SellTicket : 余票数:2374
[373 ms] [INFO][main] i.k.e.c.h.SellTicket : 数据异常

线程安全的代码

由于count变量在sell()方法中会被多个线程修改,因此需要对sell()方法进行加锁。

class TicketWindow{
    private int count;

    public TicketWindow(int count) {
        this.count = count;
    }

    public int getCount() {
        return count;
    }

    public synchronized int sell(int amount){
        if (count >= amount){
            count -= amount;
            return amount;
        }else {
            return 0;
        }
    }
}

售票问题

编写一个账户类Account类,内部有一个成员变量money记录当前的余额。
TicketWindow类有以下方法需要实现

方法返回值类型功能
Account(int money)-类构造方法,需要传入初始余额
getMoney()int获取当前余额
transfer(Account target, int amount)void向另一个用户转账,如果余额大于传入参数amount就转账,否则就不转

测试类

测试类已经完善,需要最终的输出的数据正常

public class AccountTransfer {
    public static void main(String[] args) throws InterruptedException {
        int initMoney = 10000;
        Account account1 = new Account(initMoney);
        Account account2 = new Account(initMoney);
        Random random = new Random();
        Runnable task = () ->{
            try {
                Thread.sleep(random.nextInt(20));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (random.nextInt(2)==0){
                account1.transfer(account2,random.nextInt(500)+1);
            }else {
                account2.transfer(account1,random.nextInt(500)+1);
            }
        };
        List<Thread> threads = new ArrayList<>();
        // 创建线程
        for (int i = 0; i < 2500; i++) {
            Thread thread = new Thread(task, "T-" + i);
            threads.add(thread);
        }
        log.info("开始执行");
        // 执行线程
        for (Thread thread : threads) {
            thread.start();
        }
        // 等待所有线程执行完毕
        for (Thread thread : threads) {
            thread.join();
        }
        log.info("原始总余额:{}",initMoney*2);
        log.info("账户1的余额:{}",account1.getMoney());
        log.info("账户2的余额:{}",account2.getMoney());
        log.info(account1.getMoney()+account2.getMoney()==initMoney*2?"数据正常":"数据异常");   
    }
}

线程不安全的代码

有上一题的经验,很容易可以想到在transfer()方法上加锁。但是,如果仅仅是在方法上加上synchronized并不能够解决问题,因为此时synchronized仅仅能够锁住当前对象,代码中存在两个对象相互转账,它们会同时调用transfer()方法,进而造成指令交叉。

class Account{
    private int money;

    public Account(int money) {
        this.money = money;
    }

    public int getMoney() {
        return money;
    }

    public synchronized void transfer(Account account, int amount){
        if (money >= amount){
            this.money -= amount;
            account.money += amount;
        }
    }
}

输出如下

[168 ms] [INFO][main] i.k.e.c.h.AccountTransfer : 开始执行
[435 ms] [INFO][main] i.k.e.c.h.AccountTransfer : 原始总余额:20000
[437 ms] [INFO][main] i.k.e.c.h.AccountTransfer : 账户1的余额:3491
[437 ms] [INFO][main] i.k.e.c.h.AccountTransfer : 账户2的余额:16385
[438 ms] [INFO][main] i.k.e.c.h.AccountTransfer : 数据异常

线程安全的代码

根据上面的分析,我们需要同时锁住两个对象,最简单的是利用类对象加锁,这样两个类中仅有一个可以执行transfer()方法,保证了原子性。

class Account{
    private int money;

    public Account(int money) {
        this.money = money;
    }

    public int getMoney() {
        return money;
    }

    public synchronized void transfer(Account account, int amount){
        synchronized (Account.class){
            if (money >= amount) {
                this.money -= amount;
                account.money += amount;
            }
        }
    }
}

这时有人会分析到:加入同时存在A→B的转账和C→D的转账,但是假如其中一组用户拿到锁,其它用户的转账都会被阻塞,不太合理,于是提出了如下的改进方案。

可能造成死锁的代码

class Account{
    private int money;

    public Account(int money) {
        this.money = money;
    }

    public int getMoney() {
        return money;
    }

    public synchronized void transfer(Account account, int amount){
        synchronized (this){
            synchronized (account) {
                if (money >= amount) {
                    this.money -= amount;
                    account.money += amount;
                }
            }
        }
    }
}

会发现这种方案会在中途卡死,代码不执行,这种现象称为死锁,原因是两个线程持有了对方需要的锁,而自己有不愿意放开自己的锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值