线程安全问题

线程安全问题

在多线程编程中,多个线程可以同时访问和修改共享数据,如果不进行适当的同步处理,会导致数据不一致和不可预测的行为。

一、什么时候考虑安全问题?

1.1 需要考虑线程安全问题的情况

  • 条件1:多线程的并发环境下。
  • 条件2:有共享的数据。
  • 条件3:共享数据涉及到修改的操作。

1.2 一般情况下的线程安全性分析

  • 局部变量:一般情况下局部变量不存在线程安全问题。特别是基本数据类型的局部变量,因为它们在栈中存储,栈是线程私有的,不是共享的。如果是引用数据类型的局部变量,就另当别论了,因为它们可能指向堆中的共享对象。

  • 实例变量:可能存在线程安全问题。实例变量存储在堆中,堆是多线程共享的。因此,如果多个线程同时访问和修改实例变量,可能会引起数据不一致。

  • 静态变量:也可能存在线程安全问题。静态变量在堆中存储,多个线程可能会同时访问和修改静态变量,从而引发安全问题。

1.3 解决多线程并发操作中的安全问题

为了解决多线程并发操作中的安全问题,可以使用线程同步机制,即让线程t1和线程t2排队执行,保证同一时间只有一个线程能够对共享数据进行修改。

  • 线程同步机制:t1线程在执行的时候必须等待t2线程执行到某个位置之后,t1线程才能执行。只要t1和t2之间发生了等待,就认为是同步。这种机制虽然效率低,但是可以保证数据的安全性。

  • 线程异步机制:t1和t2各自独立执行,不需要等待对方。这种机制效率高,但是可能存在安全隐患,因为并发执行时可能会导致数据不一致。

二、同步代码块

2.1 线程同步机制保证多线程并发环境下的数据安全

1. 线程同步的本质

线程同步的本质是让多个线程排队执行共享资源的操作,确保同一时间只有一个线程能够访问和修改共享资源。这可以防止数据竞争和不一致问题。

2. 语法格式

使用 synchronized关键字来实现线程同步。语法格式如下:

1
2
3
synchronized (共享对象) {
// 需要同步的代码
}

这里的“共享对象”是用于线程同步的锁对象,必须是所有需要同步的线程共享的对象。如果选择不当,可能会导致不必要的线程同步,从而降低效率。

3. 原理

synchronized关键字使用对象锁来实现同步。当一个线程进入同步代码块时,它需要先获取对象锁。只有获得对象锁的线程才能执行同步代码块,其它线程必须等待锁被释放。

4. 注意同步代码块的范围

同步代码块的范围应该尽可能小,以提高效率。过大的同步范围会导致更多的线程等待,从而降低程序性能。

代码解析

  • lock对象用于实现线程同步。
  • synchronized (lock)块确保同一时间只有一个线程可以执行其中的代码。
  • 当一个线程进入 synchronized块,它需要获取 lock对象的锁。
  • 当一个线程持有锁时,其他线程必须等待,直到锁被释放。
  • 确保 synchronized块尽可能小,以提高并发效率。

2.2 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package com.camellia.thread.ThreadSafe;

public class ThreadSafeSolve {
public static void main(String[] args) {
// 创建账户对象
AccountSolve act = new AccountSolve("act-001", 10000);
// 创建线程对象1
Thread t1 = new Thread(new WithdrawSolve(act));
// 创建线程对象2
Thread t2 = new Thread(new WithdrawSolve(act));
// 启动线程
t1.start();
t2.start();
}
}

/**
* 取款线程
*/
class WithdrawSolve implements Runnable {

//实例变量(共享数据)
private AccountSolve act;

public WithdrawSolve(AccountSolve act) {
this.act = act;
}

@Override
public void run() {
this.act.withdraw(1000);
}
}


/**
* 银行账户
*/
class AccountSolve {

private static Object object = new Object();
private String actNo;
private double balance;

public AccountSolve() {}

public AccountSolve(String actNo, double balance) {
this.actNo = actNo;
this.balance = balance;
}

public double getBalance() {
return balance;
}

public void setBalance(double balance) {
this.balance = balance;
}

public String getActNo() {
return actNo;
}

public void setActNo(String actNo) {
this.actNo = actNo;
}

/**
* 取款方法
* @param money 取款额度
*/
public void withdraw(double money) {
synchronized (this) { //这里填obj也可以,只要是需要排队线程的共享对象。(但是obj是静态变量,会全部加锁。)
double before = this.balance;
System.out.println(Thread.currentThread().getName() + "线程正在取款" + money + ",当前" + this.getActNo() + "账户余额" + before);

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//第二步:修改余额
this.setBalance(before - money);
System.out.println(Thread.currentThread().getName() + "线程取款成功,当前" + this.getActNo() + "账户余额" + this.getBalance());
}
}

}

这里act是锁,多个线程抢一把锁。

2.3 结论

线程同步机制通过使用对象锁来确保多线程环境下的数据安全。选择合适的锁对象并合理控制同步代码块的范围,可以有效地解决数据竞争问题,并尽量减少对程序性能的影响。

三、同步实例方法

在 Java 中,除了使用同步代码块外,还可以使用同步实例方法来实现线程同步。同步实例方法会自动使用方法所属对象(即调用方法的对象)作为锁对象,从而确保同一时间只有一个线程可以执行该实例方法。

3.1 同步实例方法的语法

在 Java 中,如果将 synchronized 关键字放在实例方法的声明上,就可以将该方法声明为同步方法。其语法如下:

1
2
3
public synchronized void methodName() {
// 同步代码块
}

3.2 示例代码

假设我们有一个银行账户类 BankAccount,其中包含一个同步实例方法 withdraw,用来模拟多个线程同时对同一个账户进行取款操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.camellia.thread.ThreadSafe;

public class ThreadSafeSolve2 {
public static void main(String[] args) {
// 创建账户对象
AccountSolve act = new AccountSolve("act-001", 10000);
// 创建线程对象1
Thread t1 = new Thread(new WithdrawSolve(act));
// 创建线程对象2
Thread t2 = new Thread(new WithdrawSolve(act));
// 启动线程
t1.start();
t2.start();
}
}

/**
* 取款线程
*/
class WithdrawSolve2 implements Runnable {

//实例变量(共享数据)
private AccountSolve act;

public WithdrawSolve2(AccountSolve act) {
this.act = act;
}

@Override
public void run() {
this.act.withdraw(1000);
}
}


/**
* 银行账户
*/
class AccountSolve2 {

private static Object object = new Object();
private String actNo;
private double balance;

public AccountSolve2() {}

public AccountSolve2(String actNo, double balance) {
this.actNo = actNo;
this.balance = balance;
}

public double getBalance() {
return balance;
}

public void setBalance(double balance) {
this.balance = balance;
}

public String getActNo() {
return actNo;
}

public void setActNo(String actNo) {
this.actNo = actNo;
}

/**
* 取款方法
* @param money 取款额度
*/
public synchronized void withdraw(double money) { //设置同步实例变量
double before = this.balance;
System.out.println(Thread.currentThread().getName() + "线程正在取款" + money + ",当前" + this.getActNo() + "账户余额" + before);

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//第二步:修改余额
this.setBalance(before - money);
System.out.println(Thread.currentThread().getName() + "线程取款成功,当前" + this.getActNo() + "账户余额" + this.getBalance());
}
}

代码解析

  • withdraw 方法使用了 synchronized 关键字修饰,成为一个同步实例方法。
  • 当线程调用 withdraw 方法时,会自动获取当前对象(account)的锁对象,因此同一时间只有一个线程可以执行 withdraw 方法。
  • 其他线程在执行 withdraw 方法时,如果此时有线程已经占有了 account 对象的锁,则必须等待锁被释放后才能继续执行。

3.3 使用同步实例方法的优势

  • 简单性:不需要显式指定锁对象,直接使用方法所属对象作为锁。
  • 可读性:代码更加清晰,易于理解。
  • 一致性:同一对象的不同同步方法共享同一把锁,保证了对象内部状态的一致性。

3.4 注意事项

  • 同步实例方法使用的是当前对象作为锁,因此在多线程环境中,需要确保同一对象的同步方法互斥执行,以防止竞态条件和数据不一致问题。
  • 同步实例方法的锁粒度比较粗,如果需要更细粒度的控制,可以考虑使用同步代码块并指定特定的锁对象。

注意:若synchronized加在静态方法上就是对类加锁,仔细思考就是对类的静态变量加了锁。