通过锁定在 Java 中实现线程安全的 ArrayList
Posted
技术标签:
【中文标题】通过锁定在 Java 中实现线程安全的 ArrayList【英文标题】:Implement a thread-safe ArrayList in Java by locking 【发布时间】:2018-03-08 07:07:12 【问题描述】:我想写一个简单的线程安全数组列表,它支持:
add()、remove(int i)、insert(int i)、update(int i) 和 get(int i)
一个简单的实现是给内部数据结构(例如一个对象数组)加锁,但这还不够好,因为一次只有一个线程可以访问列表。
因此我最初的计划是为每个数据槽添加锁,以便不同的线程可以同时访问不同索引中的元素。数据结构如下所示:
class MyArrayList
Lock listlock;
Lock[] locks;
Object[] array;
如果不需要resize(),锁定应该如下工作:
对于get(int i),线程需要获取锁[i]。 对于insert(int i),线程需要获取j >= i 和listlock 的所有锁[j]。 对于 remove(int i),线程需要获取所有锁 [j] for j >= i 和 listlock。 对于add(),线程需要获取listlock。 对于insert(),线程需要获取锁[i]。我的问题是:
在添加更多对象的同时调整大小时如何处理锁,我需要创建一个新的更大的数组来保存所有对象。这很烦人,因为其他一些线程也可能在等待锁被释放, 有没有更好的建议来实现这种线程安全的数组列表?【问题讨论】:
List list = Collections.synchronizedList(new ArrayList());
呢?
那个存在,就叫Vector。
我认为这是一个练习,而不是一个简单的练习,因为 JDK 中有内置的并发集合。
@DwB 感谢您提供的信息。其实我从Vector或者synchronizedList的源码中发现,synchronized关键字是用来保证线程安全的。以 get() 为例,线程 A 想要调用 get(0),而线程 B 想要调用 get(1),使用 synchronized 将只允许一个线程调用 get() 函数,即使两个线程正在尝试读取数据不同的位置。如果线程正在访问不同的插槽,我想做的是同时执行此操作。
这绝不是合理的。您不能合理地限制列表上的同步规模,因为有多种方法可以访问列表中的给定元素(迭代器、枚举器、按索引访问),并且将列表增加一个元素可能会导致完全重建数组(即分配新数组,复制旧元素,转储旧数组)。
【参考方案1】:
一个简单的方法是只使用read-write lock ([Reentrant]ReadWriteLock
),这样许多线程可以同时读取,但是一旦有人获得写入锁,其他人就无法访问该列表。
或者你可以做一些类似于你的想法的事情:每个插槽一个读写锁+一个全局(“结构”)读写锁+一个变量来跟踪j >= i
案例。所以:
int modifyingFrom
变量,指示从那里开始的所有位置都被“锁定”(j >= i
案例) .设置modifyingFrom
后,您将(参见docs)从写锁定降级为读锁定,让其他人访问列表。
任何试图做任何非结构变化的线程,一旦持有全局读锁,就会检查它想要做的事情是否与modifyingFrom
的当前值冲突。如果有冲突,休眠直到设置modifyingFrom
的线程完成并通知所有等待的人。此检查必须同步(只需在某个对象上使用synchronized (obj)
),因此在冲突线程调用obj.wait()
并永远休眠(持有全局读锁!)之前,obj.notify()
不会发生结构更改线程。 :(
当没有发生结构变化时,您应该有一个boolean structuralChangeHappening = false
或将modifyingFrom
设置为一些x > <list size>
(然后您可以检查i < modifyingFrom
到get()
或update()
)。完成结构更改的线程将 modifyingFrom
设置回该值,这是它必须同步以通知等待线程的地方。
一个线程想要在结构更改已经发生时进行更改将等待,因为它需要全局写锁并且至少一个线程具有全局读锁。事实上,它会一直等到根本没有人访问该列表。
分配新(更大...或更小,如果您有 trimToSize()
或其他)数组的线程将在整个操作期间持有全局写入锁 >.
我很想认为全局读写锁并不是真正需要的,但最后两点证明了这一点。
一些例子:
一些线程试图get(i)
(每个线程都是i
,是否唯一):每个线程都会获得全局读锁,然后是i
th 读锁,然后读取位置,没有人会等待。
同样的情况,有额外的线程尝试update([index =] i, element)
:如果没有相等的i
s,没有人会等待。否则,只有写入的线程或读取冲突位置的线程会等待。
一个线程t
启动一个insert([index =] 5, element)
,而其他线程尝试get(i)
: 一旦t
设置了modifyingFrom = 5
并释放了全局写锁,所有读取的线程都获得了全局读取锁定,然后检查modifyingFrom
。带有i < modifyingFrom
的人只需获得插槽的(读)锁;其他人等到insert(5)
完成并通知,然后获取插槽的锁定。
一个线程启动一个add()
并需要分配一个新数组:一旦它获得全局写锁,在它完成之前没有其他人可以做任何事情。
列表的大小是 7,一个线程 t_a
调用 add(element)
,另一个线程 t_g
调用 get([index =] 7)
:
如果t_a
碰巧首先获得了全局写锁,它会设置modifyingFrom = 7
,一旦它释放了锁,t_g
就会获得全局读锁,看到index (= 7) >= modifyingFrom
并休眠直到t_a
完成并通知它。
如果t_g
首先获得全局读锁,它会检查7 < modifyingFrom
(modifyingFrom > <list size> (== 7)
,示例之前的第4点),然后抛出异常,因为7 >= <list size>
在释放锁之后! 那么t_a
就能获得全局写锁并正常进行。
记住对modifyingFrom
的访问必须同步。
你说你只需要这五个操作,但如果你想要一个迭代器,它可以检查是否通过其他方式(不是迭代器本身)改变了某些东西,就像标准类一样。
现在,我不知道在什么条件下这会比其他方法更好。此外,请考虑在实际应用程序中您可能需要更多限制,因为这应该只确保一致性:如果您尝试读取和写入相同的位置,则读取可能发生在写入之前或之后。也许有像tryUpdate(int, E)
这样的方法是有意义的,它只有在调用该方法时没有发生冲突的结构更改时才起作用,或者tryUpdate(int, E, Predicate<ArrayList>)
,它只有在列表处于满足谓词(应谨慎定义以免导致死锁)。
如果我遗漏了什么,请告诉我。可能有很多极端案例。 :)
【讨论】:
以上是关于通过锁定在 Java 中实现线程安全的 ArrayList的主要内容,如果未能解决你的问题,请参考以下文章