BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

Realm Java 原理介绍以及常见问题

| 作者 陈牧龙 关注 0 他的粉丝 发布于 2016年11月18日. 估计阅读时间: 14 分钟 | GMTC大前端的下一站,PWA、Web框架、Node等最新最热的大前端话题邀你一起共同探讨。

Realm 简介

Realm 与 MVCC

Realm 是一个 MVCC 数据库 ,底层用 C++ 编写。MVCC 指的是多版本并发控制。

MVCC 解决了一个重要的并发问题:在所有的数据库中都有这样的时候,当有人正在写数据库的时候有人又想读取数据库了(例如,不同的线程可以同时读取或者写入同一个数据库)。这会导致数据的不一致性 - 可能当你读取记录的时候一个写操作才部分结束。如果数据库允许这种事情发生,你就会得到和最终数据库里的数据不一致的数据。

有很多的办法可以解决读、写并发的问题,最常见的就是给数据库加锁。在之前的情况下,我们在写数据的时候就会加上一个锁。在写操作完成之前,所有的读操作都会被阻塞。这就是众所周知的读-写锁。这常常都会很慢。

类似 Realm 的 MVCC 的数据库采用了另外的一个方法:每一个连接的线程都会有数据在一个特定时刻的快照。

如上图所示:假设线程1正在读取 Realm 数据库的 V1 版本,与此同时,线程2需要写入数据库,创建一个新的 R1 节点以修改 V1 版本中的 R 节点;R1 节点的右子树仍然指向原 B 节点,左子树指向新建的 A1 节点;A1 节点的右子树仍然指向原 D 节点,左子树指向新创建的 C1 节点。

在线程2写入的过程中,线程1的读取操作并不会被阻塞,其仍然能够正常访问数据库版本 V1 的所有节点。

请看上图中的第三部分,当线程2写入完成,线程1之前的读取操作也完成,于是线程1决定刷新以得到最新的数据库更改。这时线程1也同步到了数据库的 V2 版本,所有在第二部中线程2对数据库的更改都对线程1可见。R 和其他相应的节点都替换成了线程2写入的新信息,同时原节点 C、A 和 R 不再被任何线程需要,变成了垃圾节点,将会在之后的写操作中被回收。

Realm 的懒加载

大部分的时候,你都把数据存在磁盘上的数据库文件中。开发者发起一个从持久化机制(比如 ORM 或者 Core Data)中获取数据的请求,数据格式会是和本地平台密切相关的(比如安卓或者苹果)。这个时候,持久化机制会把请求转换成一系列的 SQL 语句,创建一个数据库连接(如果没有创建的话),发送到磁盘上,执行查询,读取命中查询的每一行的数据,然后存到内存里(这里有内存消耗)。之后你需要把数据序列化成可在内存里面存储的格式,这意味着比特对齐,这样 CPU 才能处理它们。

最后,数据需要转换成语言层面的类型,然后它会以对象的形式返回,这样平台才能用(POJO, NSManagedObject 等等)来处理它。如果你在你的持续化机制中有子引用或者列表引用的话,这个过程会更复杂。这个过程会一遍一遍的执行(取决于你的持续化机制和配置)。如果你使用自产自销的机制,情况也大致相同。

Realm 的方法不一样。这就是我们零拷贝架构起作用的地方。

Realm 跳过了整个拷贝过程,因为数据库文件是 memory-mapped。Realm 在访问文件偏移的时候就好像文件已经在内存中一样,实际上不是,而是虚拟内存。这是个 Realm 核心文件格式的重要设计决定。它允许文件能在没有做任何反序列化的情况下可以在内存中读取。

Realm 跳过了所有这些开销很大的步骤,而这些步骤在传统的持久化机制中必须执行。Realm 只需要简单地计算偏移来找到文件中的数据,然后从原始访问点返回数据结构(POJO/NSManagedObject/等等)的值 。这更有效而且更快。

Realm Java 介绍

上文中所提到的 Realm 与 MVCC 相关的概念在所有的 Realm 产品中都适用,接下来我们介绍一下在 Realm Java 中这些概念是怎么与 Java 语言和 安卓框架相结合并实现的。

线程

在 Realm Java 中你可以使用 Realm.getInstance()(或者Realm.getDefaultInstance())来在当前线程中获得一个 Realm 实例。Realm 使用引用计数管理每个线程中的 Realm 实例。多次针对同一个 RealmConfiguration 在同一线程中调用会返回同一个 Realm 实例。Realm 实现了 Closeable 接口,这意味这每一次的 getInstance() 调用都应该对应一个 close() 调用以释放相应的资源。

如果 getInstance() 是第一次在当前线程调用,那么它会在当前最新的数据版本之上打开一个新的 Realm 实例。

对于一个拥有安卓 Looper 的线程,Realm 通过安卓的 Handler 系统来通知各个线程中的 Realm 实例有写入操作发生。举例来说,假设线程1是安卓 UI 线程,当线程2中对 Realm 进行了写入操作后,线程1的 Realm 会在下一次 Looper 事件中更新到线程2写入后的数据版本。

对于一个非 Looper 线程来说,Realm 的数据版本更新依赖于 Realm.waitForChange() 调用。该调用会阻塞当前线程直到其他线程有写入操作完成。

Realm 对象代理和字节码替换

Realm 通过使用注解处理和字节码变换来联系 RealmObject 和 Realm 数据存储。我们通过下面这个简单的例子来了解一下这个过程。例如我们有如下类定义:

当工程编译完成后,Realm 的注解处理器会生成如下 DogRealmProxy.java:

请注意这里的 realmGet$xxx() 和 realmSet$xxx() 函数。RealmObject 正是通过这些函数来与 Realm 数据库打交道的。当然这还不是 Realm 全部的秘密,如果反编译 build/intermediates/transforms/xxx/xxx/Dog.class 文件,你会发现它与你之前定义的 Dog.java 并不完全一样:

首先,我们注意到了有四个与 DogRealmProxy 类一一对应新的方法(realmGet$xxx/realmSet$xxx)被插入到了 Dog 类中,这四个新方法只是简单的 setter 和 getter;其次,在函数 getAge()、setAget() 以及 printName 中所有对 Dog 属性的直接访问都被替换成了相应生成的方法 realmGet$xxx 和 realmSet$xxx。

这就是全部的秘密所在了。在从 Realm 实例中获取任何 Realm 对象的时候(比如调用 Realm.createObject() 或者 RealmQuery.findFirst()),你实际上是获取了这个对象相应的 Realm 代理对象。对其属性的访问实际上都是通过相应生成的方法来访问底层的 Realm 数据库来实现的。

同时这也解释了我们之前提到的 Realm 的懒加载特性。在查询返回一个或者多个 Realm 对象的时候,这些对象的属性并没有被拷贝到 Java 堆中,这使得 Realm 的查询非常得快。这些属性只在需要被访问的时候,才经由生成的 getter 方法加载。

Realm Java 常见问题

在了解了 Realm 的这些关键实现之后,如下这些常见问题也就不难解释了。

跨线程 Realm 访问

Realm access from incorrect thread. Realm objects can only be accessed on the thread they were created.

这是一个初次使用 Realm 时常见的异常。请注意,RealmObject、RealmResults等相关对象都是与其线程中的 Realm 实例绑定的。因为两个线程中的 Realm 实例可能锁定了不同的 Realm 版本,这些对象也可能处于不同的数据版本,跨线程访问会引起数据的不一致性。所以,在另一个线程中访问同一个对象的时候,请在该线程中进行查询以获得这个对象绑定该线程 Realm 的实例。或者使用 Realm 提供的相应的异步查询接口,具体请参考相关文档。

托管 Realm 对象与非托管 Realm 对象

在 Realm 文档里这两个概念(managed Realm object/unmanaged Realm Object)尝尝被提及。通过以上的介绍,我们不难想象这里的 托管 Realm 对象(managed Realm object)指的是 Realm 的代理对象实例,例如 DogRealmProxy的实例;而非托管 Realm 对象指的是 (unmanaged Relam object)原始对象的实例,例如 Dog 的实例。

我们也不难想象,对于非托管 Realm 对象来说,他可以经由类似 new Dog() 的方式创建,而且对它本身属性的访问并不会引起任何对 Realm 数据库的访问。

当然,非托管 Realm 对象仍然可以被保存到 Realm 数据库中并且相应地返回一个托管 Realm 对象,例如:

如上代码中的 Realm.copyToRealm() 会将传入的非托管对象保存到 Realm 中并且返回一个托管 Realm 对象。

另外,显而易见,非托管 Realm 对象不具备 Realm 托管对象的一切高级特性,比如自动更新特性。

重复主键异常

在调用 Realm.createObject(Class<E> clazz) 或类似函数时,下列异常有可能被抛出:

Primary key constraint broken. Value already exists: 0

这是因为 Realm.createObject(Class<E> clazz) 实际上隐式调用了原始对象的默认无参数构造器,然后通过 Realm.copyToRealm() 方法将其存入 Realm 中。隐式构造器会给其主键属性赋一个默认值,而当第二次调用时,主键仍会是这个默认值。这就导致了 Realm 存储的对象出现了重复主键,从而异常被抛出。解决方法有很多种,譬如调用 Realm.createObject(Class<E> clazz, Object primaryKeyValue) 方法在对象创建时指定一个不重复的主键。

Realm 数据库文件不断增大

让我们来看看如下代码:

这里声明的 AsyncTask 会在每次执行的时候打开一个 Realm 实例,但是并没有在使用结束后关闭。通过我们对 Realm 线程相关的介绍,不难想象这会导致某一 Realm 数据版本被该线程中的 Realm 实例锁定,因为 Realm 实例没有被正确关闭,Realm 无法得知其对应的数据已经不需要再被访问。假设这个 AsyncTask 在后台被反复执行,同时又有另一个线程在不断更新着 Realm 的数据,那么每一个 AyncTask 都会锁定一个不同的 Realm 数据版本,从而导致 Realm 文件的体积不断变化。所以,请在后台线程结束时关闭相应的 Realm 实例。

Realm 库与 apk 大小

Realm 几乎发布了针对所有 ABI 的 so 文件。如果你的应用在 google play 市场发布,那么你可以很方便的通过 google 官方提供的 apk Split 将各种 ABI 分开打包。但假设你的应用是在国内市场发布,ABI Split 可能无法正常工作,你可以考虑只包含部分 so 文件(例如 arm64 设备兼容 armeabi 和 armeabi-v7a,而只支持 armeabi 的设备几乎没有人使用了)。具体信息可以查看 Realm 的文档。


感谢徐川对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT