Java线程锁定特定对象

3

我有一个Web应用程序,使用的是Oracle数据库,我有一个基本上像这样的方法:

public static void saveSomethingImportantToDataBase(Object theObjectIwantToSave) {
      if (!methodThatChecksThatObjectAlreadyExists) {
         storemyObject() //pseudo code
     }
     // Have to do a lot other saving stuff, because it either saves everything or nothing
     commit() // pseudo code to actually commit all my changes to the database.
}

目前没有任何形式的同步,因此n个线程可以自由地访问该方法,当2个线程进入该方法时,它们都会进行检查,当然还没有什么内容,然后它们都可以提交事务,从而创建重复对象。

我不想通过数据库中的唯一键标识符来解决这个问题,因为我认为我不应该捕获那个SQLException异常。

我也不能在提交之前检查,因为有几个检查条件,不仅仅是1,这需要相当长的时间。

我的锁和线程经验有限,但我的想法基本上是锁定接收到的对象的代码。例如,如果我接收一个整数对象,并锁定值为1的整数,那么只有具有另一个值为1的整数的线程才会被阻止进入,所有其他值为value!= 1的线程都可以自由进入吗?这是它的工作原理吗?

此外,如果是这样,如何比较锁定对象?如何确定它们实际上是相同的对象?欢迎提供好的文章。

你会如何解决这个问题?


1
为什么你认为你不应该捕获SQL异常?Oracle内置了由非常聪明的人开发了几十年,并且由成千上万的安装用户每天使用的功能,能够解决你的问题。他们的轮子运转良好。一旦你的应用程序变得足够大并需要进行集群处理时,你会对这种内存锁定感到后悔的。 - Affe
@Affe 实际上,该应用程序已经在Tomcat集群中了,因此我确实担心线程锁定的问题...我想捕获SQL异常可能会起作用,我只是想看看其他可能性。捕获该SQL异常的问题在于我必须检查它是否是正确的ORA编号,这个编号是否经常更改? - Oscar Gomez
10个回答

5

您的想法很好。这是一个简单/天真的版本,但不太可能奏效:

public static void saveSomethingImportantToDataBase(Object theObjectIwantToSave) {
    synchronized (theObjectIwantToSave) {
        if (!methodThatChecksThatObjectAlreadyExists) {
            storemyObject() //pseudo code
        }
        // Have to do a lot other saving stuff, because it either saves everything or nothing
        commit() // pseudo code to actually commit all my changes to the database.
    }
}

这段代码使用对象本身作为锁。但它必须是相同的对象(即objectInThreadA == objectInThreadB),才能正常工作。如果两个线程正在操作一个彼此相同的对象副本 - 即具有相同的“id”等,那么您需要同步整个方法:

    public static synchronized void saveSomethingImportantToDataBase(Object theObjectIwantToSave) ...

这样做当然会大大降低并发性(吞吐量将降至使用该方法的一个线程 - 应避免)。

或者找到一种方法,基于相同的对象获取相同的锁对象,像这种方法:

private static final ConcurrentHashMap<Object, Object> LOCKS = new ConcurrentHashMap<Object, Object>();
public static void saveSomethingImportantToDataBase(Object theObjectIwantToSave) {
    synchronized (LOCKS.putIfAbsent(theObjectIwantToSave.getId(), new Object())) {
        ....    
    }
    LOCKS.remove(theObjectIwantToSave.getId()); // Clean up lock object to stop memory leak
}

推荐使用最新版本:它将确保共享相同“id”的两个保存对象锁定相同的锁对象 - 方法ConcurrentHashMap.putIfAbsent()是线程安全的,因此“这将起作用”,并且仅需要objectInThreadA.getId().equals(objectInThreadB.getId())才能正常工作。此外,由于Java的自动装箱,getId()的数据类型可以是任何类型,包括基本类型(例如int)。

如果您为对象覆盖了equals()hashcode()方法,则可以使用对象本身而不是object.getId(),这将是一种改进(感谢@TheCapn指出这一点)。

这个解决方案仅适用于一个JVM中。如果您的服务器是集群的,则情况就完全不同了,Java的锁定机制将无法帮助您。您将需要使用集群锁定解决方案,但这超出了本答案的范围。


@OscarMk - 你的意思是Java如何知道这两个线程引用了同一个对象?在这种情况下,它在内存中的身份足以知道它正在访问哪个对象。如果想更全面地了解你的问题,请查阅“读者/写者问题”,这是一个流行的并发问题。 - Grambot
1
只要你在对象中重写.equals()方法以便对属性进行比较,你就能得到想要的结果。 - Grambot
1
这正是我寻找的。但似乎在删除调用时存在同步错误。如果一个线程已经完成保存并删除了锁定,第二个线程已经开始工作,那么第三个线程可能会在第二个线程完成之前工作。 - And390
1
@Bohemian 这段测试代码显示错误:https://ideone.com/zEDBv3(如果我没记错的话,它还需要处理putIfAbsent返回的null)。 - And390
1
@Bohemian 这个问题可以通过引用计数来解决remove调用。例如:https://ideone.com/CYSvEh。 - And390
显示剩余12条评论

3
这里有一个选项,改编自And360在Bohemian的答案中的评论,试图避免竞争条件等问题。虽然我更喜欢这个问题的另一个答案,但这个选项也不错。
import java.util.HashMap;
import java.util.concurrent.atomic.AtomicInteger;

// it is no advantage of using ConcurrentHashMap, since we synchronize access to it
// (we need to in order to "get" the lock and increment/decrement it safely)
// AtomicInteger is just a mutable int value holder
// we don't actually need it to be atomic
static final HashMap<Object, AtomicInteger> locks = new HashMap<Integer, AtomicInteger>();

public static void saveSomethingImportantToDataBase(Object objectToSave) {
    AtomicInteger lock;
    synchronized (locks) {
        lock = locks.get(objectToSave.getId());
        if (lock == null) {
            lock = new AtomicInteger(1);
            locks.put(objectToSave.getId(), lock);
        }
        else 
          lock.incrementAndGet();
    }
    try {
        synchronized (lock) {
            // do synchronized work here (synchronized by objectToSave's id)
        }
    } finally {
        synchronized (locks) {
            lock.decrementAndGet();
            if (lock.get() == 0)  
              locks.remove(id);
        }
    }
}

您可以将这些拆分为辅助方法,“获取锁对象”和“释放锁”,以清理代码。 这种方式比我的其他答案感觉有点笨拙。

2

如果一个线程在同步块中,而另一个线程从Map中删除同步对象等问题,Bohemian的答案似乎存在竞争条件问题。因此,这里提供一种利用WeakRef的替代方案。

// there is no synchronized weak hash map, apparently
// and Collections.synchronizedMap has no putIfAbsent method, so we use synchronized(locks) down below

WeakHashMap<Integer, Integer> locks = new WeakHashMap<>(); 

public void saveSomethingImportantToDataBase(DatabaseObject objectToSave) {
  Integer lock;
  synchronized (locks) {
    lock = locks.get(objectToSave.getId());
    if (lock == null) {
      lock = new Integer(objectToSave.getId());
      locks.put(lock, lock);
    }
  }
  synchronized (lock) {
    // synchronized work here (synchronized by objectToSave's id)
  }
  // no releasing needed, weakref does that for us, we're done!
}

以下是如何使用上述样式系统的更具体示例:

static WeakHashMap<Integer, Integer> locks = new WeakHashMap<>(); 

static Object getSyncObjectForId(int id) {
  synchronized (locks) {
    Integer lock = locks.get(id);
    if (lock == null) {
      lock = new Integer(id);
      locks.put(lock, lock);
    }
    return lock;
  }
}

然后在其他地方像这样使用它:
...
  synchronized (getSyncObjectForId(id)) {
    // synchronized work here
  }
...

这能够起作用的基本原因是,如果两个具有匹配键的对象进入关键块,则第二个将检索第一个已经使用的锁定(或留下并尚未被GC处理的锁定)。然而,如果它没有被使用,两者都已离开该方法并删除了对锁定对象的引用,因此它可以安全地被收集。
如果您有一个有限的“已知大小”的同步点要使用(不必最终减小),则可能可以避免使用HashMap,并改用ConcurrentHashMap,其putIfAbsent方法可能更易于理解。

1
WeakRef是一个好主意,但我认为也许你的答案似乎存在竞态问题,就像And390评论中所说。模拟:Thread1添加lock1,并通过他的key1(new Integer(1234))引用。 Thread2尝试getSyncObjectForId,key2(new Integer(1234)),获取T1的lock1,并被锁定。 Thread1离开,我认为key2 !== key1,因此没有其他对key1的引用,由于我们使用了weakmap,当进行GC时,这个key1-lock1条目可能会从映射中删除。 Thread3到来,映射中没有锁,它进入条件区域,而Thread2仍在运行! - tianzhipeng
出色的解决方案。 - tianzhipeng

1
如果您可以容忍偶尔的过度同步(即不必要时按顺序完成的工作),请尝试以下操作:
  1. 创建一个带锁对象的表。表越大,超同步的机会就越少。
  2. 对您的ID应用一些哈希函数来计算表索引。如果您的ID是数字,可以使用余数(模)函数,如果是字符串,则使用hashCode()和余数。
  3. 从表中获取锁并对其进行同步。

一个IdLock类:

public class IdLock {

private Object[] locks = new Object[10000];

public IdLock() {
  for (int i = 0; i < locks.length; i++) {
    locks[i] = new Object();
  }
}

public Object getLock(int id) {
  int index = id % locks.length;
  return locks[index];
}

}

和它的使用:

private idLock = new IdLock();

public void saveSomethingImportantToDataBase(Object theObjectIwantToSave) {
  synchronized (idLock.getLock(theObjectIwantToSave.getId())) {
    // synchronized work here
  }
}

1

我的观点是你并没有遇到真正的线程问题。

最好让数据库管理系统自动分配一个不冲突的行 ID。

如果需要使用现有的行 ID,请将它们存储为线程本地变量。如果没有共享数据的必要,请勿在线程之间共享数据。

http://download.oracle.com/javase/6/docs/api/java/lang/ThreadLocal.html

相比于应用服务器或Web容器,Oracle数据库管理系统在保持数据一致性方面更加出色。

许多数据库系统在插入行时会自动生成唯一键字段。Oracle数据库通过序列和触发器提供了相同的功能。JDBC 3.0引入了检索自动生成键特性,使您能够检索这些生成的值。在JDBC 3.0中,以下接口被增强以支持检索自动生成键特性...."

http://download.oracle.com/docs/cd/B19306_01/java.102/b14355/jdbcvers.htm#CHDEGDHJ


问题在于直到commit()被调用之前,实际上什么都没有被插入。 - Oscar Gomez
我认为在最终提交事务之前,您应该能够检索密钥。假设pstmt是您的预处理语句,并且您已经执行了它。 ResultSet generatedKeys = pstmt.getGeneratedKeys(); - Joerg Ruethschilling

0
public static void saveSomethingImportantToDataBase(Object theObjectIwantToSave) {
  synchronized (theObjectIwantToSave) {

      if (!methodThatChecksThatObjectAlreadyExists) {
         storemyObject() //pseudo code
      }
 // Have to do a lot other saving stuff, because it either saves everything or nothing
      commit() // pseudo code to actually commit all my changes to the database.
  }
}

synchronized 关键字锁定您想要的对象,以便其他方法无法访问它。


0

使用synchronized关键字(或其他同步操作)是必须的,但对于您的问题来说并不足够。您应该使用数据结构来存储使用了哪些整数值。在我们的示例中,使用了HashSet。不要忘记从哈希集中清除旧记录。

private static HashSet <Integer>isUsed= new HashSet <Integer>();

public synchronized static void saveSomethingImportantToDataBase(Object theObjectIwantToSave) {

      if(isUsed.contains(theObjectIwantToSave.your_integer_value) != null) {

      if (!methodThatChecksThatObjectAlreadyExists) {
         storemyObject() //pseudo code
      }
 // Have to do a lot other saving stuff, because it either saves everything or nothing
      commit() // pseudo code to actually commit all my changes to the database.
      isUsed.add(theObjectIwantToSave.your_integer_value);

  }
}

0
关于锁定整数的问题,简短的回答是不行 - 它不能防止具有相同值的另一个整数实例的线程进入。长答案:取决于您如何获取整数 - 通过构造函数、重用某些实例或使用一些缓存的valueOf方法。无论如何,我都不会依赖它。
一个可行的解决方案是使该方法同步:
public static synchronized void saveSomethingImportantToDataBase(Object theObjectIwantToSave) {
    if (!methodThatChecksThatObjectAlreadyExists) {
        storemyObject() //pseudo code
    }
    // Have to do a lot other saving stuff, because it either saves everything or nothing
    commit() // pseudo code to actually commit all my changes to the database.
}

这可能不是性能最佳的解决方案,但它保证可以工作(注意,如果您不在集群环境中),直到您找到更好的解决方案。


0

我认为你没有选择,只能采取你似乎不想做的解决方案之一。

在你的情况下,我认为在objectYouWantToSave对象上进行任何类型的同步都不会起作用,因为它们是基于Web请求的。因此,每个请求(在自己的线程上)很可能会有它自己的对象实例。即使它们被认为是逻辑上相等的,这对于同步也没有关系。


0
private static final Set<Object> lockedObjects = new HashSet<>();

private void lockObject(Object dbObject) throws InterruptedException {
    synchronized (lockedObjects) {
        while (!lockedObjects.add(dbObject)) {
            lockedObjects.wait();
        }
    }
}

private void unlockObject(Object dbObject) {
    synchronized (lockedObjects) {
        lockedObjects.remove(dbObject);
        lockedObjects.notifyAll();
    }
}

public void saveSomethingImportantToDatabase(Object theObjectIwantToSave) throws InterruptedException {
    try {
        lockObject(theObjectIwantToSave);

        if (!methodThatChecksThatObjectAlreadyExists(theObjectIwantToSave)) {
            storeMyObject(theObjectIwantToSave);
        }
        commit();
    } finally {
        unlockObject(theObjectIwantToSave);
    }
}
  • 你必须正确地覆盖对象类的方法'equals''hashCode'。如果你的对象内有唯一的id(字符串或数字),那么你可以只检查这个id而不是整个对象,无需覆盖'equals'和'hashCode'。
  • try-finally非常重要——即使你的操作抛出异常,你也必须保证在操作后解锁等待线程。
  • 如果你的后端分布在多个服务器上,则此方法将无法使用。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接