并发4️⃣管程②变量线程安全、案例练习
1、变量线程安全
成员变量、静态变量、局部变量
1.1、成员变量 & 静态变量
1.1.1、判断依据(❗)
变量有无在线程间共享。
- 无共享:线程安全。
- 共享
- 只有读操作:线程安全。
- 读写操作:临界区,需考虑线程安全。
1.1.2、示例
有 2 个方法对成员变量 list 进行写操作。
-
多线程下,每个线程操作的都是同一个变量,即局部变量 list。
-
存在线程安全问题。
class Demo{ ArrayList<String> list = new ArrayList<>(); public void save(String str){ list.add(str); } public void delete(){ list.remove(0); } } @Test public void test(){ Demo demo = new Demo(); new Thread(()->{ for (int i = 0; i < 5000; i++){ } }) }
线程不安全情况:两个线程同时调用 save()
说明:ArrayList 的 add() 分为扩容、赋值和计数(修改 size)
线程不安全分析:对同一个位置的元素赋值,对原有值进行覆盖(甚至发生两次计数)
- 线程 t1 对位置 i 的元素赋值,在计数之前发生上下文切换。
- 线程 t2 也对位置 i 的元素赋值(覆盖),并且计数 +1
- 线程 t1 计数。
测试:两个线程同时开启,各调用 5000 次 save()
-
int frequency = 5000; new Thread(() -> { for (int i = 0; i < frequency; i++) { stringList.save(i + ""); } }, "t1").start(); new Thread(() -> { for (int i = 0; i < frequency; i++) { stringList.save(i + ""); } }, "t2").start(); SleepUtils.sleepSeconds(5); System.out.println(stringList.list.size());
-
结果是 9912,说明存在对同一位置赋值的情况。
1.2、局部变量
1.2.1、基本类型
8 大基本数据类型
不存在线程安全问题。
每个线程在执行相应方法时,会在栈帧内存中创建一份独有的变量。
-
public void test1() { int i = 10; i++; }
1.2.2、引用类型(❗)
需进行逃逸分析
- 引用的对象没有逃离方法作用范围:线程安全。
- 引用的对象逃离方法作用范围(如作为返回值),需考虑线程安全。
1.3、常见线程安全类
线程安全类:多线程调用以下类的同一个实例的某个方法时,是线程安全的。
- String、StringBuffer、包装类型(如 Integer)
- Random
- Vector、Hashtable
- JUC 包下的类
原子性说明
类中的每个方法具有原子性,但方法的组合不具有原子性。
示例:Hashtable
-
put() 和 get() 方法都带有 synchronized 关键字,方法具有原子性。
public synchronized V put(K key, V value) {...} public synchronized V get(Object key) {...}
-
2 个方法进行组合。
private Hashtable<String, String> table = new Hashtable<>(); public void addIfNotExist(String key, String value){ if (table.get(key) == null) { table.put(key, value); } }
两个线程同时调用该方法
-
线程 1 判断 table 中没有元素为 key,但在添加元素 "v1" 之前发生线程上下文切换。
-
线程 2 判断 table 中没有元素为 key,添加元素 "v2"。
-
线程 1 继续执行,将 "v1" 添加到 table 中,把线程 2 添加的 "v2" 值覆盖了。
1.4、线程安全分析(❗)
分析思路:找共享变量,找临界区。
case 1 - Servlet 成员变量
MyServlet 是单例的
多线程下
-
map:线程不安全。
-
str、_STR:不可变类,线程安全。
-
D1:引用类型,线程不安全。
-
D2:无法改变引用值,但属性仍可修改,线程不安全。
public class MyServlet extends HttpServlet { Map<String,Object> map = new HashMap<>(); String str = ""; final String _STR = ""; Date D1 = new Date(); final Date D2 = new Date(); doGet(request, response) { // 读写以上变量 } }
case 2 - Servlet 调用方法
MyServlet 是单例的
多线程下
-
线程不安全
-
userService 是成员变量,update() 是临界区。
public class MyServlet extends HttpServlet { // 是否安全? private UserService userService = new UserServiceImpl(); public void doGet(HttpServletRequest request, HttpServletResponse response) { userService.update(); } } public class UserServiceImpl implements UserService { // 记录更新次数 private int count = 0; public void update() { count++; } }
case 3 - AOP
MyAspect 是单例的
多线程下
-
线程不安全
-
多线程共享 start 变量,before() 是临界区。
-
解决:改用环绕通知,将变量声明为局部变量。
@Aspect @Component public class MyAspect { // 是否安全? private long start = 0L; @Before("execution(* *(..))") public void before() { start = System.nanoTime(); } @After("execution(* *(..))") public void after() { long end = System.nanoTime(); System.out.println("cost time:" + (end-start)); } }
case 4 - MVC 无状态
MyServlet、UserServiceImpl、UserDaoImpl 是单例的
无状态类保证了线程安全。
从最内层开始分析
-
UserDaoImpl :没有成员变量(无状态),Connection 是引用类型的局部变量,且没逃离方法作用范围,线程安全
-
UserServiceImpl:userDao 是无状态成员变量,线程安全
-
myServlet:userService 是无状态成员变量,线程安全
public class MyServlet extends HttpServlet { // 安全? private UserService userService = new UserServiceImpl(); public void doGet(request, response) { userService.update(); } } public class UserServiceImpl implements UserService { // 安全? private UserDao userDao = new UserDaoImpl(); public void update() { userDao.update(); } } public class UserDaoImpl implements UserDao { public void update() { String sql = "UPDATE user SET password = ? WHERE username = ?"; // 安全? try (Connection conn = DriverManager.getConnection("","","")){ // ... } catch (Exception e) { // ... } } }
case 5 - MVC 有状态
与 case4 的区别:UserDaoImpl 中的 Connection 是成员变量(有状态)
-
**UserDaoImpl **:update() 属于临界区,线程不安全
-
UserServiceImpl、MyServlet:线程不安全
public class UserDaoImpl implements UserDao { private Connection conn = null; public void update() throws SQLException { String sql = "UPDATE user SET password = ? WHERE username = ?"; conn = DriverManager.getConnection("","",""); // ... conn.close(); } }
case 6 - MVC 局部变量
与 case5 的区别:UserServiceImpl 和 UserDaoImpl 是局部变量。
外层的局部变量保证了内层成员变量的线程安全问题。
-
UserDaoImpl:Connection 是成员变量,update() 属于临界区,线程不安全
-
UserServiceImpl:UserDaoImpl 是局部变量,线程安全
-
MyServlet:UserServiceImpl 是局部变量,线程安全
public class MyServlet extends HttpServlet { // 是否安全 private UserService userService = new UserServiceImpl(); public void doGet(request, response) { userService.update(); } } public class UserServiceImpl implements UserService { public void update() { UserDao userDao = new UserDaoImpl(); userDao.update(); } } public class UserDaoImpl implements UserDao { // 是否安全 private Connection = null; public void update() throws SQLException { String sql = "update user set password = ? where username = ?"; conn = DriverManager.getConnection("","",""); // ... conn.close(); } }
case 7 - 抽象方法
foo 是抽象方法
具体行为在运行时确定(多态),可能导致线程不安全。
public abstract class DiyDateFormat {
public void bar() {
// 安全?
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
foo(sdf);
}
public abstract foo(SimpleDateFormat sdf);
}
2、案例练习
分析以下代码的线程安全问题,并加以改正。
2.1、卖票
模拟一个售票窗口,多线程相当于购票者。
2.1.1、售票窗口
class TicketWindow {
private int amount;
public int buy(int i) {
if (amount >= i) {
amount -= i;
return i;
}
return 0;
}
// 有参构造、getter
}
2.1.2、模拟购票
模拟:售票窗口有 1000 张票,有 2000 人购票。
- 用 Vector 记录实际售票数(Vector 线程安全)
- 用 ArrayList 存储线程,以便在主线程调用
join()
等待结束(ArrayList 仅主线程使用,无需考虑线程安全)
思路
-
创建线程,调用 buy() 并记录当前线程售票数(Random 线程安全)。
-
将线程加入到 ArrayList,开启线程。
-
主线程等待所有线程执行结束,计算实际的售票总数。
-
查看实际售票数和余票数,判断相加是否为 1000。
private static void buy() throws InterruptedException { TicketWindow window = new TicketWindow(2000); Vector<Integer> sellCounts = new Vector<>(); ArrayList<Thread> threads = new ArrayList<>(); for (int i = 0; i < 2000; i++) { Thread thread = new Thread(() -> { // 竞态条件 int sellCount = window.buy(randomAmount()); sellCounts.add(sellCount); }); threads.add(thread); thread.start(); } for (Thread thread : threads) { thread.join(); } int soldCount = 0; for (Integer buyCount : sellCounts) { soldCount += buyCount; } LogUtils.debug("Sold: " + soldCount); LogUtils.debug("Left: " + window.getAmount()); } // 该类线程安全 static Random random = new Random(); private static int randomAmount() { return random.nextInt(5) + 1; }
2.1.3、分析
-
临界区:多线程对 TicketWindow 的 amount 变量进行读写,发生竞态条件。
int sellCount = window.buy(randomAmount());
-
解决:在 buy() 方法上加 synchronized 关键字。
public synchronized int buy(int i) { ... }
2.2、转账
模拟两个人互相转账。
2.2.1、账户
class Account {
private int balance;
public void transfer(Account desc, int amount) {
if (balance >= amount) {
this.balance -= amount;
desc.balance += amount;
}
}
// 有参构造、getter
}
2.2.2、模拟转账
模拟:两个账户各有 1000 元,互相转账 1000 次。
private static void play() throws InterruptedException {
Account a1 = new Account(1000);
Account a2 = new Account(1000);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
a1.transfer(a2, getRandom());
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
a2.transfer(a2, getRandom());
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("total: " + (a1.getBalance() + a2.getBalance()));
}
private static final Random RANDOM = new Random();
public static int getRandom() {
return RANDOM.nextInt(100) + 1;
}
2.2.3、分析
Account 的 transfer() 是临界区,需要加锁。
分析以下 2 种解决方案
- 在方法上加 synchronized:相当于
synchronized(this)
- 线程 t1 持有 a1 的对象锁。
- t2 不受影响,执行
transfer()
时能拿到 a2 的锁并调用 。 - 无法解决问题。
synchronized(Account.class)
- 线程 t1 持有 Account 类的对象锁。
- t2 执行
transfer()
时,尝试获取锁时阻塞。 - 可以解决线程安全问题。