【问题标题】:Is Map of Locks a safe approach for concurrent operationsMap of Locks 是并发操作的安全方法吗
【发布时间】:2016-08-12 06:00:33
【问题描述】:

要求只允许单个线程执行用户管理(创建/更新/导入)操作,但不允许多个线程同时为同一用户执行用户操作。例如,当线程 A 正在创建用户 A 时,线程 B 不能同时导入用户 A 或创建用户 A,但允许线程 B 导入用户 B。以下代码线程对于这些要求是否安全?

public class UserManagement {

    ConcurrentHashMap<Integer, Lock> userLock = new ConcurrentHashMap<>();

    public void createUser(User user, Integer userId) {
        Lock lock = userLock.putIfAbsent(userId, new ReentrantLock());
        try {
            lock.lock();
            //create user logic
        } finally {
            lock.unlock();
        }
    }

    public void importUser(User user, Integer userId) {
        Lock lock = userLock.putIfAbsent(userId, new ReentrantLock());
        try {
            lock.lock();
            //import user logic
        } finally {
            lock.unlock();
        }
    }

    public void updateUser(User user, Integer userId) {
        Lock lock = userLock.putIfAbsent(userId, new ReentrantLock());
        try {
            lock.lock();
            // update user logic
        } finally {
            lock.unlock();
        }
    }
}

【问题讨论】:

  • 看起来是线程安全的,但是一个奇怪的设计。如果 User 刚刚被创建,另一个线程怎么可能对它做些什么呢?
  • 我投票结束这个问题,因为它是一个代码审查请求
  • 可以移至代码审查
  • 库 Javadoc 说 lock.lock() 可能会失败并引发异常。如果发生这种情况,您不想致电lock.unlock()。您的lock.lock() 调用应出现在 try 关键字之前。
  • 这三种方法中的每一种都会在每次调用时创建一个新的ReentrantLock 实例,无论是否需要。这是无害的,但它可能会导致一些开发人员在阅读您的代码时扬起眉毛。 ---- 当我进一步考虑时,看起来你的方式比做必要的额外工作来防止构建不必要的锁更干净(并且可能更快)。唯一的难题是如何阻止其他开发人员想要“修复”您的代码。也许您应该发起一场运动,将其命名为设计模式。

标签: java multithreading locking


【解决方案1】:

您的代码符合安全访问用户操作的要求,但它不是完全线程安全的,因为它不能保证所谓的初始化安全。如果您创建UserManagement 的实例并在多个线程之间共享它,那么在某些情况下,这些线程可能会看到未初始化的userLock。虽然不太可能,但仍有可能。

要使您的类完全线程安全,您需要将final 修饰符添加到您的userLock,在这种情况下,Java 内存模型将保证在多线程环境中正确初始化此字段。将不可变字段设置为 final 也是一种很好的做法。

重要更新: @sdotdi 在 cmets 中指出,在构造函数完成工作后,您可以完全依赖对象的内部状态。其实,这不是真的,事情更复杂。

评论中提供的链接仅涵盖 Java 代码编译的早期阶段,并没有说明进一步发生的事情。但更进一步,优化开始发挥作用,并开始根据需要重新排序指令。代码优化器的唯一限制是 JMM。根据 JMM 的说法,使用构造函数中发生的事情来更改指向对象新实例的指针的分配是完全合法的。所以,没有什么可以阻止它优化这个伪代码:

UserManagement _m = allocateNewUserManagement(); // internal variable
_m.userLock = new ConcurrentHashMap<>();
// constructor exits here
UserManagement userManagement = _m;  // original userManagement = new UserManagement()

到这个:

UserManagement _m = allocateNewUserManagement();
UserManagement userManagement = _m;
// weird things might happen here in a multi-thread app
// if you share userManagement between threads
_m.userLock = new ConcurrentHashMap<>();

如果您想防止这种行为,您需要使用某种同步:synchronizedvolatile 或更柔和的final,如本例所示。

您可以找到更多详细信息,例如,在“Java 并发实践”一书的第 3.5 节“安全发布”中。

【讨论】:

  • 我不认为你是对的。或者,更准确地说,我很确定你错了。如果有人使用new 持有UserManagement,则表示构造函数完成。根据规范docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.5,首先,在构造新对象期间,它调用super(),然后初始化实例变量,例如userLock 映射,然后运行构造函数的其余部分。因此,如果有人获得了对此类对象的引用,userLock 将被完全初始化。
  • 我实际上已经拿起了 JCiP 并阅读了第 3.5 节。对不起,看来我错了。我也有点失望,我不能依赖规范。他们至少可以在我链接到的部分中指出此类出版问题。
  • @sdotdi,不要对规范感到失望,它绝对正确和可靠,但是很难掌握它的所有部分并且它有其局限性,因为它只是一种语言,而不是整个平台, 规格。实际上,它有一个关于内存模型的部分,在一开始就解释了重新排序:docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4
  • @sdotdi,但随后 JVM 出现了自己的规范、编译器和优化器,事情变得更加模糊,更不用说硬件级别了。在这个无情的 Java 并发世界中,JCiP 是一个非常好的(虽然有时有点过时)指南,请不要在仅阅读第 3.5 节后将其丢弃 :)
【解决方案2】:

除了 Andrew Lygin 提到的那个之外,您的程序还有另一个错误。

这会将lock 设置为null,如果之前没有看到过userId,因为`putIfAbsent(...) 不会返回新值,它会返回previous 值:

Lock lock = userLock.putIfAbsent(userId, new ReentrantLock());

改为这样做:

Lock lock = userLock.computeIfAbsent(userId, k -> new ReentrantLock());

computeIfAbsent(...) 返回 值。此外,它还有一个额外的好处,即除非实际需要,否则不会实际创建新的 Lock 对象。 (感谢@bowmore 的建议。)


这个程序线程安全吗?

假设您修复了错误,我们仍然无法告知 程序。我们所能知道的是,您的 UserManagement 类的实例不允许对同一 userId 的这三个方法中的任何一个进行重叠调用。

这是否使您的程序线程安全取决于您如何使用它。例如,您的代码不允许两个线程同时更新相同的userId,但如果它们尝试,它将允许它们一个接一个地进行。您的代码将无法控制哪个先执行 --- 操作系统会这样做。

您的锁定可能会阻止两个线程将用户记录置于无效状态,但它们会将其置于正确状态吗?该问题的答案超出了您向我们展示的这一类的范围。

线程安全不是 可组合 属性。也就是说,完全用线程安全的类构建一些东西并不能保证整个东西都是线程安全的。

【讨论】:

  • 感谢詹姆斯的回答。在 jdk1.8 中添加了 computeIfAbsent,但 jdk1.7 的解决方案可能是什么
【解决方案3】:

只要您不在锁定的逻辑代码块中创建任何新线程,它看起来就是线程安全的。如果您确实在锁定的逻辑代码块中创建了线程,并且这些线程会为同一用户调用任何 UserManagement 方法,那么您最终会陷入死锁。

您还需要确保只有一个 UserManagement 实例。如果您创建多个实例,那么您可以让多个线程更新同一个用户。我建议将 userLock 设为静态以避免该问题。

只是另一个与应用程序逻辑有关的小问题。传入用户时,需要确保不会传入具有不同 userId 的同一个用户(不知道为什么将 userId 与用户对象分开传入)。这需要该类之外的附加逻辑来创建/导入新用户。否则,您最终可能会调用 createUser(userA, 1) 和 createUser(userA,2) 或 import(userA,3)。

【讨论】:

    【解决方案4】:

    有一些问题:

    1. 地图无限增加
    2. 不应在 try-final 中调用 lock
    3. 如果存在键,则不要创建对象

    现在解决第一个问题并不容易 - 仅调用 userLock.remove(userId); 是不够的:

    public class UserManagement {
    
        private final ConcurrentHashMap<Integer, Lock> userLock = new ConcurrentHashMap<>();
    
        public void createUser(User user, Integer userId) {
            Lock lock = userLock.computeIfAbsent(userId, k -> new ReentrantLock());
            lock.lock();
            try {
                // do user logic
            } finally {
                lock.unlock();
            }
            // Danger: this could remove the lock although another thread is still inside the 'user logic'
            userLock.remove(userId);
        }
    }
    

    据我目前所知,您可以解决所有问题、节省一点内存并避免显式锁定。根据 javadocs 的唯一要求是“用户逻辑”要快:

    // null is forbidden so use the key also as the value to avoid creating additional objects
    private final ConcurrentHashMap<Integer, Integer> userLock = ...;
    
    public void createUser(User user, Integer userId) {
      // Call compute if existing or absent key but do so atomically:
      userLock.compute(userId, (key, value) -> {
          // do user logic
          return key;
      });
      userLock.remove(rowIndex);
    }
    

    【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2020-05-07
    • 1970-01-01
    • 2013-10-12
    • 2011-01-11
    • 1970-01-01
    • 2011-11-04
    • 1970-01-01
    • 2010-12-23
    相关资源
    最近更新 更多