Java 多线程同步机制

文章目录

  • Java 多线程同步机制
  • 1 线程并发问题
  • 2 synchronized 关键字
  • 2.1 包裹同步代码块
  • 2.2 修饰非静态方法
  • 2.3 修饰静态方法
  • 3 线程同步实例演示
  • 3.1 线程不安全情况演示
  • 3.2 使用 synchronized 关键字包裹同步代码块
  • 4 实际开发中遇到线程安全问题的解决思路
  • 5 扩展细节知识
  • 5.1 Java 三大变量
  • 5.2 JDK 中常用类线程安全

1 线程并发问题

  • 实际开发中,项目运行在服务器上,服务器会定义线程、创建线程对象、启动线程等。我们更多关注的是编写的程序放到多线程环境下运行是否安全。

经典银行案例

java 如何保证单线程执行 java多线程保证数据一致_java 如何保证单线程执行

  • 两条线程同时操作,或者是在一方取款时出现了网络延迟,导致取完钱后未及时更新数据。
  • 此时另一人用相同的账户继续取钱,并且取出了钱,那么就是线程不安全的。

需要考虑线程安全问题的前提条件:

  • 程序存在多线程并发
  • 有共享数据
  • 共享数据有修改的行为

线程同步机制解决多线程数据安全问题:

  • 让线程排队,不能并发,可以保证数据安全,专业术语称 线程同步机制
  • 线程排队会牺牲一部分效率,从大局看,数据安全排在第一位,只有数据安全了,考虑效率才有意义。

2 synchronized 关键字

  • synchronized 是用于实现线程同步机制的关键字,可以包裹代码块、可以修饰方法、可以修饰。

2.1 包裹同步代码块

synchronized(共享对象){
    同步代码块
}
  • 共享对象是需要排队线程的共享对象
  • 同步代码块是需要排队执行的代码片段
  • Java 语言中,任何一个对象都有一把“锁”(标记)
  • 在运行状态中的线程遇到 synchronized 关键字后会进入锁池寻找对象锁
  • 如果找到对象锁,就回到就绪状态抢夺 CPU 时间片。
  • 如果该对象锁被占用,则会在锁池中等待该锁被归还,可以理解为阻塞在锁池中。

2.2 修饰非静态方法

public synchronized void doIt(){
    同步代码
}
  • 代码简洁,但不灵活,因为共享对象只能是 this
  • 修饰方法时,表示整个方法体都需要同步,可能会扩大锁范围,降低效率,根据实际情况选择加锁方式。
  • 当共享方法的整个方法体都需要加锁,且共享对象就为 this,则建议采用这种方式加锁,代码更加简洁。

2.3 修饰静态方法

public static synchronized void doIt(){
    同步代码
}
  • 如果修饰某个类的静态方法,表示给当前类加锁,所有的类对象都共享同一个把锁。
  • 静态方法属于类,非静态方法属于对象。

以上锁都是排他锁,还有互斥锁

3 线程同步实例演示

线程同步 demo 有很多,如售票系统、银行取款等。这里演示前面提出的银行取款模拟。

3.1 线程不安全情况演示

账户类 Account

public class Account {
    // 名字
    private String name;
    // 余额
    private double money;

    public Account(String name, double money) {
        this.name = name;
        this.money = money;
    }
	// 以下省略 setter 、 getter 、 toString 等方法
	
	// 取钱方法
	public boolean takeMoney(double money) {
        // 查询余额
        double before = this.getMoney();
        // 判断取款金额是否大于账户余额
        if (money > before) return false;
        // 取款操作
        double after = before - money;
        System.out.println(Thread.currentThread().getName() + "取款操作完成!");
        // 模拟网络延迟,放大问题
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 更新余额
        this.setMoney(after);
        System.out.println(Thread.currentThread().getName() + "余额更新完成");
        return true;
    }
}

线程类 AccountThread

public class AccountThread extends Thread {
    // 两个线程共享一个对象
    private Account account;
    // 取款金额
    private double money;

    public AccountThread(Account account, double money) {
        this.account = account;
        this.money = money;
    }

    @Override
    public void run() {
        // 取款操作,线程不安全
        boolean flag = account.takeMoney(money);
        if (flag) {
            System.out.println(Thread.currentThread().getName() + "取款成功,取完后余额为:" + account.getMoney());
        } else {
            System.out.println(Thread.currentThread().getName() + "取款失败");
        }
    }
}

主线程 main 方法

public class Test {
    public static void main(String[] args) {
        // 初始化账户
        Account account = new Account("aaa", 10000.0);
        // 线程对象
        Thread t1 = new AccountThread(account, 5000);
        Thread t2 = new AccountThread(account, 5000);
        // 线程名
        t1.setName("t1");
        t2.setName("t2");
        // 启动线程
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("最终账户余额:" + account.getMoney());
    }
}

运行结果:

java 如何保证单线程执行 java多线程保证数据一致_多线程_02

  • 这种情况就是不安全的,账户一共 10000 元,取了两次 5000 后还剩下 5000!
  • 如果多次运行也会出现线程安全的情况,取了两次 5000 后,余额为 0

3.2 使用 synchronized 关键字包裹同步代码块

public boolean takeMoney(double money) {
    synchronized (this) {  // 能锁住,只有一个 Account 对象,且是共享的,共享该对象的线程需要排队,如果不是同一个 Account 对象,则不需要排队
    //synchronized (name) {  // 能锁住,只有一个 String 对象,且是共享的
    //synchronized (new Object()) {  // 不能锁住,多个 Object 对象,这个是局部变量,不被共享。
    //synchronized ("abc"){  // 能锁住,"abc" 在常量池中,被所有线程共享,所以所有线程执行这里时都会排队。
        double before = this.getMoney();
        if (money > before) return false;
        double after = before - money;
        System.out.println(Thread.currentThread().getName() + "取款操作完成!");
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.setMoney(after);
        System.out.println(Thread.currentThread().getName() + "余额更新完成");
        return true;
    }
}
  • 共享对象有很多选择,根据实际情况选择即可。
  • 不做其他修改,继续运行刚刚的主函数,结果如下:

java 如何保证单线程执行 java多线程保证数据一致_synchronized_03

  • 可以看出,使用 synchronized 包裹同步代码块后,线程安全执行。
  • 从输出的信息中可以看出,当 t1 线程更新完成后,t2 线程才进入 takeMoney() 方法。

4 实际开发中遇到线程安全问题的解决思路

  • synchronized 会降低程序执行效率,用户体验不好。
  • 在不得已的情况下使用线程同步机制。
  • 方案一:尽量使用局部变量代替实例变量和静态变量,局部变量处于栈中,不被多线程共享,所以不存在线程安全问题。
  • 方案二:如果必须实例化对象,可以考虑实例化多个对象,一个线程对应一个对象,这样实例变量的内存就不被共享了。
  • 方案三:如果不能使用局部变量,也不能创建多个对象,就只能选择 synchronized 实现线程同步机制了,此时应当尽量缩小同步范围。
  • 同步代码机制必然会降低运行效率,所以尽量避免使用。

5 扩展细节知识

5.1 Java 三大变量

  • 实例变量:存放在堆中,只有一个,被多线程共享,可能存在线程安全问题。
  • 静态变量:存放在方法区,只有一个,被多线程共享,可能存在线程安全问题。
  • 局部变量:存放在栈中,一个线程一个栈,栈数据不被多线程共享,不存在安全问题。

5.2 JDK 中常用类线程安全

  • StringBuffer 是线程安全的,方法使用 synchronized 关键字修饰
  • StringBuilder 是非线程安全的。
  • 局部变量使用 StringBuilder ,因为局部变量保存在栈中,不存在线程安全问题,效率会更高。
  • ArrayList 是非线程安全的
  • Vector 是线程安全的
  • HashMap、HashSet 是非线程安全的
  • Hashtable 是线程安全的

参考文章: