各类变量的线程安全问题

成员变量的线程安全问题

成员变量的线程是否安全需要分情况而定:

  1. 如果成员变量没有被共享,那么就没有线程安全问题
  2. 如果它被共享了,根据它们的状态是否能够改变,又分为下面的两种情况:
  • 如果只是执行读操作,那么也不存在线程安全问题
  • 如果执行了读写操作,则读写操作的代码段就是临界区,需要考虑线程安全问题

    静态变量的线程安全问题

    静态变量的线程是否安全需要分情况而定:
  1. 如果静态变量没有被共享,那么就没有线程安全问题
  2. 如果它被共享了,根据它们的状态是否能够改变,又分为下面的两种情况:
  • 如果只是执行读操作,那么也不存在线程安全问题
  • 如果执行了读写操作,则读写操作的代码段就是临界区,需要考虑线程安全问题

局部变量的线程安全问题

  1. 局部变量是线程安全的
  2. 如果是局部变量引用的对象,则需要分情况讨论:
  • 如果对象未逃离方法的作用范围,则不存在线程安全问题
  • 否则,需要考虑线程安全问题

image.png

分析变量的线程安全问题

成员变量

对于成员变量而言,多个线程操作的是同一个对象,那么就必须要考虑线程安全问题。

  1. public class ThreadUnsafe {
  2. ArrayList<String> list = new ArrayList<>();
  3. public void method1(int loopNumber) {
  4. for (int i = 0; i < loopNumber; i++) {
  5. // { 临界区, 会产生竞态条件
  6. method2();
  7. method3();
  8. // } 临界区
  9. }
  10. System.out.println(list.size());
  11. }
  12. private void method2() {
  13. list.add("1");
  14. }
  15. private void method3() {
  16. list.remove(0);
  17. }
  18. static final int THREAD_NUMBER = 2;
  19. static final int LOOP_NUMBER = 200;
  20. public static void main(String[] args) {
  21. ThreadUnsafe test = new ThreadUnsafe();
  22. for (int i = 0; i < THREAD_NUMBER; i++) {
  23. new Thread(() -> {
  24. test.method1(LOOP_NUMBER);
  25. }, "Thread" + i).start();
  26. }
  27. }
  28. }

image.png
image.png
Thread-0还未add,此时list.size()==0,Thread-1就调用了list的remove方法,所以导致异常的抛出。

局部变量

  1. public class ThreadSafe {
  2. public final void method1(int loopNumber) {
  3. ArrayList<String> list = new ArrayList<>();
  4. for (int i = 0; i < loopNumber; i++) {
  5. method2(list);
  6. method3(list);
  7. }
  8. System.out.println(list.size());
  9. }
  10. private void method2(ArrayList<String> list) {
  11. list.add("1");
  12. }
  13. private void method3(ArrayList<String> list) {
  14. list.remove(0);
  15. }
  16. static final int THREAD_NUMBER = 2;
  17. static final int LOOP_NUMBER = 200;
  18. public static void main(String[] args) {
  19. ThreadSafe test = new ThreadSafe();
  20. for (int i = 0; i < THREAD_NUMBER; i++) {
  21. new Thread(() -> {
  22. test.method1(LOOP_NUMBER);
  23. }, "Thread" + i).start();
  24. }
  25. }
  26. }

image.png
image.png
由于Thread-0和Thread-1操作的对象是两个不同的对象,因为两个线程压根操作的不是一个对象,所以局部变量是不存在线程安全问题的。

思考: 局部变量是否真的线程安全?如果把method2或者method3的private修改为public,是否可能会存在线程不安全的隐患?

如果ThreadSafe类的子类重写了method2或者method3的方法,并且在其中开启了一条线程去进行list.remove操作,那么可能存在线程安全问题。如:

  1. class ThreadSafeSubClass extends ThreadSafe{
  2. @Override
  3. public void method3(ArrayList<String> list) {
  4. new Thread(()->{
  5. list.remove(0);
  6. }).start();
  7. }
  8. }

image.png
因此method2和method3修饰为private而不修饰为public是有原因的,就是为了让子类不要继承这个方法,免得子类重写这个方法后乱搞,搞到线程不安全。

注意: 从上面的例子可以看出 private 或 fifinal 提供【安全】的意义所在。

常见的线程安全类

image.png
值得注意的是,这里说的线程安全类是指多个线程调用它们的实例对象的同一个方法时的线程安全,如:

  1. Hashtable table = new Hashtable();
  2. new Thread(()->{
  3. table.put("key", "value1");
  4. }).start();
  5. new Thread(()->{
  6. table.put("key", "value2");
  7. }).start();

但是,如果它们的多个方法组合在一起就不是线程安全了,比如下面这段代码:

  1. Hashtable table = new Hashtable();
  2. // 线程1,线程2
  3. if( table.get("key") == null) {
  4. table.put("key", value);
  5. }

为什么线程不安全,可以看下面这个图:
image.png
在线程1发生上下文切换时,这时候线程1记住了get(“key”)==null,然后线程2也到了get(“key”)==null,然后put进去,后面又切换到线程1,但是前面线程1记住了get(“key”)==null,于是又执行了一次put,导致整段码执行下来不符合想要“只put一次”的目标。其实就是说,线程安全类的方法是一个原子,但是它们组合在一起就不是一个原子了。

案例分析

案例1

  1. public class MyServlet extends HttpServlet {
  2. // 是否安全?
  3. Map<String,Object> map = new HashMap<>();
  4. // 是否安全?
  5. String S1 = "...";
  6. // 是否安全?
  7. final String S2 = "...";
  8. // 是否安全?
  9. Date D1 = new Date();
  10. // 是否安全?
  11. final Date D2 = new Date();
  12. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  13. }
  14. }

问题分析:
HttpServlet是运行在tomcat环境下的,因此只有一个实例,会被多个线程共享。

  • HashMap不是线程安全类,所以不是线程安全
  • String是线程安全类,因此是线程安全的
  • String是线程安全,那么final String也是线程安全
  • Date D1 = new Date()不是线程安全的
  • final Date D2 = new Date()不是线程安全的,但是final修饰只是D2引用所指的对象不能改变,但不能确保这个对象的内容不被修改

    案例2

    1. public class MyServlet extends HttpServlet {
    2. // 是否安全?
    3. private UserService userService = new UserServiceImpl();
    4. public void doGet(HttpServletRequest request, HttpServletResponse response) {
    5. userService.update(...);
    6. }
    7. }
    8. public class UserServiceImpl implements UserService {
    9. // 记录调用次数
    10. private int count = 0;
    11. public void update() {
    12. // ...
    13. count++;
    14. }
    15. }

    问题分析:
    MyServlet类也是在tomcat中运行,也是单例,因此userService也是被多个线程共享的。而其成员变量count是被共享的,由于在update中count++,导致没有对count进行并发保护,所以这是线程不安全的。

    案例3

    1. @Aspect
    2. @Component
    3. public class MyAspect {
    4. // 是否安全?
    5. private long start = 0L;
    6. @Before("execution(* *(..))")
    7. public void before() {
    8. start = System.nanoTime();
    9. }
    10. @After("execution(* *(..))")
    11. public void after() {
    12. long end = System.nanoTime();
    13. System.out.println("cost time:" + (end-start));
    14. }
    15. }

    问题分析:
    在Spring环境中,所有类都是单例的,因此start是被所有线程共享的,从而导致start可能会被多个线程不同步修改,因此 start成员变量 是线程不安全的。所以最好就是将 start 作为局部变量,这样就不会存在线程安全问题了。

    案例4

    ```java 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 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) { // … } } }

  1. 问题分析:<br />Connection连接是在方法中创建的,是一个局部变量,因此它是线程安全的。成员变量userDao由于没有内容可以修改(UserDao类没有成员变量),因此userDao和不可修改其内容有着一样的妙处,所以是线程安全的。而对于成员变量userService,虽然它具有一个属性userDao,但是由于它是private修饰的,也没有方法可以修改userDao,因此成员变量userService也是线程安全的。
  2. <a name="JDS3f"></a>
  3. ## 案例5
  4. ```java
  5. public class MyServlet extends HttpServlet {
  6. // 是否安全?
  7. private UserService userService = new UserServiceImpl();
  8. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  9. userService.update(...);
  10. }
  11. }
  12. public class UserServiceImpl implements UserService {
  13. // 是否安全?
  14. private UserDao userDao = new UserDaoImpl();
  15. public void update() {
  16. userDao.update();
  17. }
  18. }
  19. public class UserDaoImpl implements UserDao {
  20. // 是否安全?
  21. private Connection conn = null;
  22. public void update() throws SQLException {
  23. String sql = "update user set password = ? where username = ?";
  24. conn = DriverManager.getConnection("","","");
  25. // ...
  26. conn.close();
  27. }
  28. }

问题分析:
在本例中,Connection是一个成员变量,它被多个线程同时访问,并且会执行 conn.close() 修改它,因此它是线程不安全的。这样一来,userDao也不是线程安全,同样,userService也不是线程安全。

案例6

  1. public class MyServlet extends HttpServlet {
  2. // 是否安全?
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  5. userService.update(...);
  6. }
  7. }
  8. public class UserServiceImpl implements UserService {
  9. public void update() {
  10. UserDao userDao = new UserDaoImpl();
  11. userDao.update();
  12. }
  13. }
  14. public class UserDaoImpl implements UserDao {
  15. // 是否安全?
  16. private Connection = null;
  17. public void update() throws SQLException {
  18. String sql = "update user set password = ? where username = ?";
  19. conn = DriverManager.getConnection("","","");
  20. // ...
  21. conn.close();
  22. }
  23. }

问题分析:
注意对比案例5,这里把userDao变成局部变量了,因此Connection就是线程安全的了。而userService也因此没有成员变量,因此也是线程安全的。

案例7

  1. public abstract class Test {
  2. public void bar() {
  3. // 是否安全?
  4. SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  5. foo(sdf);
  6. }
  7. public abstract foo(SimpleDateFormat sdf);
  8. public static void main(String[] args) {
  9. new Test().bar();
  10. }
  11. }

问题分析:
在本例中,foo是一个抽象方法,它的实现是不确定的,因此可能它的某个实现会启动一条新的线程去用到sdf,从而导致sdf不是线程安全的。

案例8

  1. private static Integer i = 0;
  2. public static void main(String[] args) throws InterruptedException {
  3. List<Thread> list = new ArrayList<>();
  4. for (int j = 0; j < 2; j++) {
  5. Thread thread = new Thread(() -> {
  6. for (int k = 0; k < 5000; k++) {
  7. synchronized (i) {
  8. i++;
  9. }
  10. }
  11. }, "" + j);
  12. list.add(thread);
  13. }
  14. list.stream().forEach(t -> t.start());
  15. list.stream().forEach(t -> {
  16. try {
  17. t.join();
  18. } catch (InterruptedException e) {
  19. e.printStackTrace();
  20. }
  21. });
  22. log.debug("{}", i);
  23. }

问题分析:
同String一样,Integer也是不可变类,单i++之后,i指向了另外一个对象,因此两个线程获取对象 i 的锁不是同一个对象了,所以导致 synchronized (i) 不起作用,因此是线程不安全的。因此可以改进为下面这样就是线程安全的了:

  1. @Slf4j(topic = "c.Test")
  2. public class Test {
  3. private static Integer i = 0;
  4. static Object obj = new Object();
  5. public static void main(String[] args) throws InterruptedException {
  6. List<Thread> list = new ArrayList<>();
  7. for (int j = 0; j < 2; j++) {
  8. Thread thread = new Thread(() -> {
  9. for (int k = 0; k < 5000; k++) {
  10. synchronized (obj) {
  11. i++;
  12. }
  13. }
  14. }, "" + j);
  15. list.add(thread);
  16. }
  17. list.stream().forEach(t -> t.start());
  18. list.stream().forEach(t -> {
  19. try {
  20. t.join();
  21. } catch (InterruptedException e) {
  22. e.printStackTrace();
  23. }
  24. });
  25. log.debug("{}", i);
  26. }
  27. }

我们只需要让线程持有同一个对象 obj 的锁才能确保i++的线程安全。

经典问题

抢票问题

  1. @Slf4j(topic = "c.Ticket")
  2. public class Ticket {
  3. // Random 为线程安全
  4. static Random random = new Random();
  5. // 随机 1~5
  6. public static int randomAmount() {
  7. return random.nextInt(5) + 1;
  8. }
  9. public static void main(String[] args) {
  10. TicketWindow ticketWindow = new TicketWindow(2000);
  11. List<Thread> list = new ArrayList<>();
  12. // 用来存储买出去多少张票,Vector是线程安全的list
  13. List<Integer> sellCount = new Vector<>();
  14. for (int i = 0; i < 3000; i++) {
  15. Thread t = new Thread(() -> {
  16. // 分析这里的竞态条件
  17. try {
  18. //模拟买票延迟
  19. Thread.sleep(randomAmount());
  20. } catch (InterruptedException e) {
  21. e.printStackTrace();
  22. }
  23. int count = ticketWindow.sell(randomAmount());
  24. sellCount.add(count);
  25. });
  26. list.add(t);
  27. t.start();
  28. }
  29. list.forEach((t) -> {
  30. try {
  31. //需要等待所有线程结束后再统计票数
  32. t.join();
  33. } catch (InterruptedException e) {
  34. e.printStackTrace();
  35. }
  36. });
  37. // 买出去的票求和
  38. log.debug("selled count:{}", sellCount.stream().mapToInt(c -> c).sum());
  39. // 剩余票数
  40. log.debug("remainder count:{}", ticketWindow.getCount());
  41. // 剩余票数+卖出去票数
  42. log.debug("sum:{}", (sellCount.stream().mapToInt(c -> c).sum() + ticketWindow.getCount()));
  43. }
  44. }
  45. class TicketWindow {
  46. private int count;
  47. public TicketWindow(int count) {
  48. this.count = count;
  49. }
  50. public int getCount() {
  51. return count;
  52. }
  53. public int sell(int amount) {
  54. if (this.count >= amount) {
  55. this.count -= amount;
  56. return amount;
  57. } else {
  58. return 0;
  59. }
  60. }
  61. }

image.png
可以看到,卖出的票和剩余的票居然不等于总票数2000,明显存在线程安全问题。原因就在于ticketWindow 对象的 count 字段是线程共享的,多个线程在 sell 方法对该对象的 count 对象进行读写操作,又没有对它进行并发保护,所以会导致线程不安全。可以对 ticketWindow 对象上锁解决,如下所示:

  1. public synchronized int sell(int amount) {
  2. if (this.count >= amount) {
  3. this.count -= amount;
  4. return amount;
  5. } else {
  6. return 0;
  7. }
  8. }

image.png
synchronized 加在方法上面其实就是 synchronized (this){ … },多个线程同时操作ticketWindow 对象的count,只有获得对象锁的线程才能操作 count 字段。

转账问题

  1. @Slf4j(topic = "c.Transfer")
  2. public class Transfer {
  3. // Random 为线程安全
  4. static Random random = new Random();
  5. // 随机 1~100
  6. public static int randomAmount() {
  7. return random.nextInt(100) + 1;
  8. }
  9. public static void main(String[] args) throws InterruptedException {
  10. Account a = new Account(1000);
  11. Account b = new Account(1000);
  12. Thread t1 = new Thread(() -> {
  13. for (int i = 0; i < 1000; i++) {
  14. a.transfer(b, randomAmount());
  15. }
  16. }, "t1");
  17. Thread t2 = new Thread(() -> {
  18. for (int i = 0; i < 1000; i++) {
  19. b.transfer(a, randomAmount());
  20. }
  21. }, "t2");
  22. t1.start();
  23. t2.start();
  24. t1.join();
  25. t2.join();
  26. // 查看转账2000次后的总金额
  27. log.debug("total:{}", (a.getMoney() + b.getMoney()));
  28. }
  29. }
  30. class Account {
  31. private int money;
  32. public Account(int money) {
  33. this.money = money;
  34. }
  35. public int getMoney() {
  36. return money;
  37. }
  38. public void setMoney(int money) {
  39. this.money = money;
  40. }
  41. public void transfer(Account target, int amount) {
  42. if (this.money > amount) {
  43. this.setMoney(this.getMoney() - amount);
  44. target.setMoney(target.getMoney() + amount);
  45. }
  46. }
  47. }

image.png
可以看到,转账转着转着就两个账号的余额不等于2000了,也是存在明显的线程安全问题。

思考: 下面这样修改可以确保线程安全吗?

  1. public synchronized void transfer(Account target, int amount) {
  2. if (this.money > amount) {
  3. this.setMoney(this.getMoney() - amount);
  4. target.setMoney(target.getMoney() + amount);
  5. }
  6. }

上面这样修改其实是不能确保线程安全的,this 只是当前对象的money,但是 transfer 方法里面涉及到两个对象,一个是this(即调用 transfer 方法的当前对象),另一个是 target 对象,synchronized(this) 很明显不能确保 this 和 target 的money的线程安全,因此我们需要锁住的是 target 和 this 共同拥有的对象:Account.Class 。如:

  1. public void transfer(Account target, int amount) {
  2. synchronized (Account.class){
  3. if (this.money > amount) {
  4. this.setMoney(this.getMoney() - amount);
  5. target.setMoney(target.getMoney() + amount);
  6. }
  7. }
  8. }

再次运行:
image.png