并发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)

image-20220327160238906

线程不安全分析:对同一个位置的元素赋值,对原有值进行覆盖(甚至发生两次计数)

  1. 线程 t1 对位置 i 的元素赋值,在计数之前发生上下文切换。
  2. 线程 t2 也对位置 i 的元素赋值(覆盖),并且计数 +1
  3. 线程 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,说明存在对同一位置赋值的情况。

    image-20220325221955293

1.2、局部变量

1.2.1、基本类型

8 大基本数据类型

不存在线程安全问题。

每个线程在执行相应方法时,会在栈帧内存中创建一份独有的变量。

  • public void test1() {
        int i = 10;
        i++;
    }
    
  • image-20220325213252187

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. 线程 1 判断 table 中没有元素为 key,但在添加元素 "v1" 之前发生线程上下文切换。

  2. 线程 2 判断 table 中没有元素为 key,添加元素 "v2"。

  3. 线程 1 继续执行,将 "v1" 添加到 table 中,把线程 2 添加的 "v2" 值覆盖了。

    image-20220325215253946

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 是单例的

无状态类保证了线程安全。

从最内层开始分析

  1. UserDaoImpl没有成员变量(无状态),Connection 是引用类型的局部变量,且没逃离方法作用范围,线程安全

  2. UserServiceImpl:userDao 是无状态成员变量,线程安全

  3. 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 仅主线程使用,无需考虑线程安全)

思路

  1. 创建线程,调用 buy() 并记录当前线程售票数(Random 线程安全)。

  2. 将线程加入到 ArrayList,开启线程。

  3. 主线程等待所有线程执行结束,计算实际的售票总数。

  4. 查看实际售票数和余票数,判断相加是否为 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 种解决方案

  1. 在方法上加 synchronized:相当于 synchronized(this)
    • 线程 t1 持有 a1 的对象锁。
    • t2 不受影响,执行 transfer() 时能拿到 a2 的锁并调用 。
    • 无法解决问题
  2. synchronized(Account.class)
    • 线程 t1 持有 Account 类的对象锁。
    • t2 执行 transfer() 时,尝试获取锁时阻塞。
    • 可以解决线程安全问题
posted @ 2022-04-05 17:47  Jaywee  阅读(43)  评论(0编辑  收藏  举报

👇