扁平化堆容器的实现原理

在上篇文章JVM常用概念之扁平化堆容器中已经对扁平化堆容器的目标及关键概念进行了介绍,此篇文档将会从OpenJDK Valhalla 项目如何实现扁平化堆容器的角度进行分析。

1.实现一致性

这里讨论的主要是在向已经占用机器字的类型添加可空性这一具有挑战性的情况下,同时(此外)避免新的竞争条件下如何实现一致性?

1.1.保留指针,缓冲值

忽略容器的声明类型并将其格式化为普通对象指针始终是合法的。因此,任何非原始容器,即使它似乎包含一个可展平的值对象,也可能包含一个单一的托管指针,指向另一个缓冲值的堆块。

从技术上讲,将值放入其自己的存储块中称为在堆上缓冲该值。诸如 Integer 之类的包装器对象可以看作是用于 int 值的类似 Valhalla 的缓冲区。有时我们说“装箱”而不是“缓冲”,尽管如今(Valhalla 之前)的装箱是通过身份对象而不是值对象来完成的。格式化为单个指针的容器始终需要缓冲其值。缓冲值是一种常见的做法。

缓冲是弱类型变量的强要求,弱类型变量可以保存对许多类的对象的引用。因此,无论 VM 多么巧妙地展平一个值,如果你将它传递给一个采用Object或接口(并且未内联)的方法,VM 可能会将你的值复制到堆缓冲区中,并将物理指针传递给该方法。

即使在 Valhalla VM 中,缓冲也可以带来明显的好处。它使用的时间和空间与当今的指针丰富的数据结构一样多,因此不会导致令人惊讶的性能变化。而且它在当今的 VM 中实现起来很简单,因为在 Valhalla 之前,基于指针的对象定位是它们唯一知道的技巧。Java 内存模型确保缓冲值即使在竞争条件下也完全一致。

空值很容易在缓冲表示中表达:只需使用零值指针而不是缓冲值的地址。

有时,即使出于更高级别的原因而排除空值,或者即使不需要完全一致性,或者即使值布局很小且容易扁平化,VM 也可能选择缓冲。在 Valhalla 之前,缓冲是使用基于值的类的唯一访问方法。即使在 Valhalla 中,它也是 Java 变量的一种缓慢但安全的通用实现选项。

缓冲可用于实现值类型的易失性字段,这些字段不易与易失性的一致性和排序要求相配合。缓冲基本上可以确保所有值的读取和写入都通过原子单指针读取和写入进行控制,指针始终指向不可变(无竞争)数据。

除了 volatile 之外,某些值可能会被缓冲,以保持完全字段一致性,这是类的默认要求。如果值对于硬件原子性来说太大,缓冲的事务性可以挽救值的一致性。代价是 GC 流失和平坦度损失。

有趣的是,对于这样的类,如果可以证明最终字段(仅初始化)的相对简单的状态转换没有竞争,那么最终字段可能会变得平坦,而可变字段则不会。

即使不考虑一致性要求,某些值类也可能被缓冲而不是直接展平。展平为半个缓存行或更少的小值带来最大的好处。(“半个”是因为它允许两个或多个相关值在分配给封闭存储块的一个缓存行中彼此相邻。)巨型值对象(多个缓存行)不太可能像只有一个或两个字的值对象那样受益,因此虚拟机可能只是“放弃”展平巨型对象的容器。静态最终字段也可能不会被展平。静态非最终字段可以展平为类似单元素数组的东西,或直接展平为关联类镜像的字段(与其他静态字段相邻)。

如果最简单的值类的实例必须与动态类型(多态)类(如 Object)交互,则它们也将被缓冲,而 Object 必须始终使用指针。没有值具有足够的魔力在动态类型 Object 数组中展平自身。……即使是小整数也不行:Java VM 不打算将某些指针“标记”为立即值,如 Smalltalk 或 Lisp fixnums。如果将 Integer 存储在 Object 变量中,它将被缓冲。

VM 解释器很可能缓冲所有值,无论它多么简单且运行良好。迄今为止,将本地分配策略混合到解释器中的实验尚未成功。

总的来说,虚拟机在布置堆容器时,可能会采用复杂的实现策略组合,并可能遵循管理这些策略的复杂规则。虚拟机会在可以避免的情况下尝试使值平坦化,但缓冲它们(如在 Valhalla 之前)始终是一种可行的后备方法。

1.2.使用小结构

有几种方法可以将值平铺到其容器中,但最简单的方法肯定是将一个小型的类似 C 的“结构”放入容器中。效果就像您从堆上完全缓冲的值开始,然后从容器中删除指向缓冲区的指针。此外,您获取缓冲区中所有字段的按位映像,并将该按位映像放入容器中。缓冲区可能有一个对象头,也可能有 VM 的堆管理器所需的填充,但您会将它们与指针一起丢弃。如果您的容器似乎需要一些填充,您可以重新添加一些填充。

从缓冲容器 C0 到结构体容器 C1 的转换可以用以下公式来描述:

C0 = R + H + F1 + F2 + ... + P0
C1 =         F1 + F2 + ... + P1
S = C0 - C1 = R + H + P0 - P1

也就是说,扁平化节省的内存是缓冲区引用的大小,加上对象头的大小,加上缓冲区所需的任何填充,减去容器所需的任何填充。

值得注意的是如果您将相同的值存储在多个容器中,并且值的大小(F1 + F2 + …)很大,您的节省(S)可能会消失。在这种情况下,缓冲表示允许将值字段仅写入内存一次,并通过指针共享。但如果值的大小很小(F1 + F2 + … ≈ R),则复制值字段通常仍然有利,而不是使用共享缓冲区。单字值(大小与 R 相当)非常适合复制,因为内存使用量不会更大,但要追踪的指针更少。

小型结构体更容易实现,因为用于缓冲区的相同结构体数据布局可重复用于容器。缓冲值看起来就像 VM 堆中的任何 Java 对象:打包到一小段内存中的一个或多个字段的集合。即使它必须与其他不相关的字段相邻(在包含扁平值作为其字段之一的对象中),或者与其他数组元素(相同类型)相邻,这组字段也可能运行良好。

要从扁平化值中提取字段,VM 可以简单地假装从缓冲值中提取该字段。(在缓冲状态下,它看起来像一个常规对象,VM 知道如何访问它。)但是,VM 必须使用不同的基地址,不是缓冲对象的基地址(当然是在标头之后),而是嵌入在保存扁平化值的其他对象中的容器的基地址。如果值嵌套在其他值中,则可以按顺序迭代该方案。

这种技术没有绝对的大小限制;原则上,它可以用来展平具有数百个字段的值。但是,与相同或相邻缓存行上的值相比,跨多个数据缓存行的值与其他附近的缓存行的关联相对较弱。使用指针查找缓冲的 100 字值比查找缓冲的 2 字值有较小的缺点。

1.3.融合和重组布局

如果一个对象包含多个扁平化为小结构的值字段,则它们(可能)彼此相邻。访问该对象就像访问一组字段一样,所有字段都从其值中解开并直接放置在顶层对象中。

有了这样的理解,我们看到虚拟机可以将所有值字段的所有子字段(并递归到所有级别)收集到一个对象中,并更有效地布局它们,就好像它们都在包含对象的类中声明一样。

也就是说当多个值彼此靠近时,有时有机会对它们的布局进行“第二次审视”,并且可能使它们的布局比单个布局的连接更好。重组后的组合布局可能具有更少或更小的碎片间隙。因此,组合结果可能适合更少的缓存行,需要更少的内存流量进行访问。另一个小优点是,组合布局可能会将托管指针彼此相邻放置,从而为 GC 带来更简单的“oop 映射”,因此它可以使用更少的循环遍历对象。如果字段标记为 @Contended,重组可能会选择将它们全部一起移动到布局的“远端”。

例如,如下述代码所示,这是一个包含两个小结构字段的玩具对象。为了逼真,此示例为两个值字段添加了一个字节的外部空通道。

value record ByteAndLong(byte b, long l) { }
value record OopAndLong(Object o, long l) { }
value MyObj {
  String a;
  ByteAndLong b;
  OopAndLong c;
  // layout:
  //   a: oop
  //   b: {long, byte}
  //   c: {long, oop}
  //   b.NC: byte, c.NC: byte
}

这是同一个对象,字段已融合并重新组织。请注意,所有托管引用 (oops) 都已移动,因此它们位于一个块中;这会使 GC 稍微快一点。长整型和字节型也已移动到各自的块中,以减少碎片。

value record ByteAndLong(byte b, long l) { }
value record OopAndLong(Object o, long l) { }
value MyObj {
  String a;
  ByteAndLong b;
  OopAndLong c;
  // layout:
  //   a: oop, c.o: oop
  //   b.l: long, c.l: long
  //   b.b: byte, b.NC: byte, c.NC: byte
}

请注意,此技术倾向于将值子字段彼此相距一定距离。例如,b 的两个子字段 b.l 和 b.b 相距 8 个字节,由 c 的字段分隔。实际上,只要分隔的子字段通常位于同一缓存行上,这种分隔就不会影响太多性能。

这种字段融合会产生成本,因此我们暂时不会尝试。好处可能是复杂布局的碎片化程度降低。一个成本是增加簿记,以跟踪每个容器的独立布局决策。另一个成本是潜在的错误,当使用错误的访问方法(错误的布局)加载值时,会导致出现垃圾字段值。另一个成本是,如果子字段彼此相距很远,则获取两个或更多缓存行的延迟,而该值只需要一个缓存行。

融合和重新组织布局在上下文上始终限于较大对象中的容器。假设每个容器都有自己的访问方法,则无需对明确定义的容器使用一致的布局。但是当一个值被缓冲时,其布局不能被熔化。当 getfield 字节码在解释器中针对缓冲值运行时,它需要一个可靠的偏移量来从中加载字段。(当超类贡献字段时,这会变得更加微妙。)这些字段偏移量是在类加载时确定的。但熔化是在包含对象的布局上下文中为已加载的值类(或其中几个)执行的额外布局操作。当加载包含对象类时,布局算法可能会选择熔化子字段并将它们与正在加载的类中的常规引用或原始字段一起混合在一起。

对于通过多态指针访问的缓冲对象,布局必须在类加载时固定,当值的字段来自抽象超类时,这种情况可能会发生多次。由于单独的类加载事件的解耦决策,固定组合布局中可能需要碎片间隙。但是,当值用作较大布局的组成部分时,熔化步骤可以撤消解耦的决策,并以更少的间隙重新设计布局。

字段融合可能发挥作用的一个地方是修复字段来自超类的值类中的碎片。在这种情况下,超类字段布局是在加载子类之前设置的,然后子类字段会围绕这些字段进行调整。这可能不是最理想的,并且可能很难将组合对象(及其子类和超类字段)放入原子包装器中。当子类在另一个容器中展平时,即使值没有进一步融入包含对象的字段中,也有机会融合并重新计算值的总布局。

在值数组的上下文中,融合也可能很有用。在这里,几个相邻值布局的块可能会被融合并重新组织为几个块,每个块属于一种类型的子字段。字节和长值的数组可以熔化为交替的块数组,该数组由 8 个字节和 8 个长值组成,重复。(对于每次重复的模式,也可能有 8 个空通道。)

任何小型结构表示(无论是否融合)都将定义一种访问方法,该方法为每个子字段执行一条加载指令。除了单个原始或引用字段的情况外,这意味着存在失去访问完全一致性的高风险。如果值类要求完全一致性(而不是松散一致性),则必须采取一些措施使多个读取都见证一组一致的字段值。当然,存储该组一致字段值的多个写入也必须作为单个逻辑操作执行。

有了这么多独立读写的子字段,我们已经做了很多事情来破坏完全一致性。在这种情况下如何保证一致性显得尤其重要。

1.4.使用原子包装器

小型结构的一个重要变体是原子包装器中的小型结构。原子包装器(如 C++ 等语言中所示)是一个包含值的盒子,对该值的所有访问均受控制,以便以完全一致的方式读取和写入该值。

最简单的原子包装器建立在机器字之上,这些机器字在一个机器指令中以原子方式读取和写入。我们称它们为原生原子包装器,因为它们由硬件原生支持。如果用于展平值容器的小结构适合 64 位(或在某些平台上适合 128 位,包括 Intel 和 ARM),则可以修改封闭对象以包含适当大小的原生原子包装器,并将小结构存储在其中。

首先,此举有两个代价。首先,原子包装器可能比实际的小结构体更大(例如,结构体为 3 个字节,而原生原子 int 为 4 个字节)。填充可能会造成损失。

其次,更重要的是,加载和存储值始终是一个两步过程,这可能会引入更多的延迟和处理周期。

访问方法的工作方式如下。要从容器中读取值,首先加载原子值(在单个机器指令中),然后使用寄存器到寄存器操作将其分解为所需的子字段。要写回新值,请反转该过程。

使用 128 位原子包装器的成本似乎特别高。在许多 CPU 上,128 位值在 VPU(矢量处理单元)中处理,与处理普通 Java 原始值的 CPU 不同。因此,将 128 位本机原子包装器解包到 Java 子字段中需要数据在 VPU 和 CPU 之间进行跨硅片传输。

可以使用其他类型的原子包装器(非本机包装器)。在 C++ 中,它们可能不平衡,包含一个小的有效负载和一个相当大的并发控制小部件(读取器/写入器锁或互斥锁),以序列化对有效负载的访问。

1.5.使用锁

恢复具有多个读取步骤(或写入步骤)的访问方法的一致性的一种方法是将整个访问置于某种锁定下,以序列化访问。这可能很难实现高性能,但在某些情况下可能比缓冲(对 GC 有压力)更可取。

为了实现可扩展性,锁应尽可能应用于较少的值。也许可以为包含一个或多个值的对象在其自己的布局中提供某种类型的锁小部件。

进一步讲,这可能是一个读/写锁,也许是Doug Lea 曾经建议的 SeqLock。

包含锁的对象中的值字段的访问方法将找到锁、抓住它,然后自由访问所有子字段。

锁可以由几个小结构共享。使用锁可以允许它们的子字段被融合和重组,而不会丢失一致性。

似乎还有一种可能,即包含对象的标头(已格式化以支持并发控制)可以作为锁使用,以实现一致的子字段访问。如果可能的话,这可能是沉没成本(对象标头)的良好利用。

如果数组要有这样的锁,那么每个大型数组都应该有许多这样的锁,以允许在不同位置进行并发访问。可以给数组赋予一系列锁定小部件字,这些字与值元素本身交错。在交替阻塞数组设计模式中,重复块可以包含一个并发控制小部件来为固定大小的元素块提供服务。有了这样的锁,数组就可以融化并重新组织每个元素块的布局。

但任何锁定部件的使用都可能会耗费更多的机器周期。

1.6.假设最终字段的一致性

值字段是不可变的,或者只能在某些锁定下以某种方式证明发生变化,不需要特别注意以确保访问的完全一致性。也就是说,如果值字段是 final(并且受到保护以防止恶意发布),则不需要并发控制小部件或原子包装器或任何此类东西。final 字段也可以安全地熔化和重组,而不会失去一致性。

2.实现空通道

为了支持所需的逻辑行为,如果容器是扁平的,但它也是可空的,则它必须表示所有扁平状态,并且作为不同的状态,表示空状态。

空通道是在扁平值容器的布局中对逻辑(非物理)空状态进行编码的特定规定。

假设值类支持 2^64 个可能的值,因此可以放入 (64 位) 机器字中。但空引用值与容器中所有 2^64 个可能的非空值不同。因此,如果 64 位值的容器也是可空的,则它必须使用不同的编码来表示额外的状态,总共需要 2^64+1 种编码。

就术语而言,我们说当将空通道设置为表示“此处为空”的状态时,该通道被置为有效状态;当将空通道设置为表示“此处为非空值”的其他状态时,该通道被撤消。从物理上讲,清除字节(或位或其他)在逻辑上会置为无效状态,而将字节设置为非零则在逻辑上会撤消它。任何非零值都可以……

这里值得留意的一点是这种尴尬源自 HotSpot VM 物理特性,它规定内存的未初始化(因此为空)状态始终为零。因此,将空通道设置为 true 意味着该值不为空。这有多清楚啊!?也许我们应该称之为非空通道?

2.1.添加一个布尔值(字节或位)

创建空通道的最直接方法是在某处添加某种布尔标志,表示“忽略其他字段,因为这个值确实为空”。这个额外的布尔值存储在附近的字节中,是最简单的空通道。它通常也是最有效的。

空通道有时被称为“空标记”、“空枢轴”、“空布尔”或“空字节”。由于它并不总是需要是物理布尔或字节,因此我们更喜欢更抽象的术语。无论其物理格式如何,空通道都有一个固定的物理特征:容器的全零位状态必须始终断言空通道。这是因为 HotSpot 总是在将物理内存用作堆存储之前将其初始化为零(全零位),并且因为 VM 规范要求引用变量一开始就表示空指针。因此,无论是否存在物理指针,物理零位将始终用于表示逻辑空引用。

空通道相对于其他字段具有不对称的作用。如果断言了它(例如,“这里有一个空值”),则所有正常值字段都将被忽略。如果空通道被撤回,则正常值字段将被视为容器中的真值。

区分空值和非空值。可以将其视为(松散且抽象地)合成布尔字段,与非空值的扁平字段一起注入。但是,有令人惊讶的多种方法来表示这一点。

顺便说一句,由于空引用不是 Long 或 Complex 类的实例,因此不可为空的 Long 或 Complex 容器不需要空通道,并且可以使用与常规 64 位原始 long 或一对 double 完全相同的布局。正如 Valhalla 所承诺的那样:您编写的代码看起来像一个类,但它的存储方式像 int(或其他原始数据)。

空通道可以小到只有一个位。(或者更小;变量的一个未使用值本身不占用一个位。)它可以从值的子字段内或附近的任何地方窃取。

但是,当我们已经承诺分配 64 位来表示 2^64 个非空值时,空通道是一个不幸的窃取位。Java 的任何主机平台上都没有对 65 位字的硬件支持。即使有,我们也不需要 65 位;我们只需要一个很小的小数位(大约 1/10^19 位)来表示额外的容器状态。

请注意,log2(log2(1+2^64)-64) 约为 -63.5。还请注意,即使我们有一百万个 64 位字段,每个字段都需要一个空通道,我们仍然可以使用 64,000,001 位来表示所有所需的状态,并且编码足够巧妙。(但解码该编码可能性能不佳,也无法抗竞争。)关键是,一个额外的小数位真的非常小。

为了简化实现,我们更有可能为那个讨厌的空通道分配一个字节(它包含的信息比我们需要的多 10^20),从而得到一个 9 字节的容器表示。但同样,硬件并没有广泛支持 9 字节值。

如果觉得有义务一直走到下一个 2 的幂,那么 64 位值的空通道本身可能包含 64 位!

有时原子容器会有额外的填充,因为值本身比容器小。或者值可能有未使用的内部填充。无论哪种情况,都可以将空通道塞入填充中,我们可以宣布胜利。我们可以将其称为内部空通道。

2.2.注意保持无效一致性

在许多其他情况下,包括具有 2^64 个点的值的困难情况,空通道只是与其余扁平值分开分配。就好像值布局部分融化了,时间刚好够空通道分离并移动到其他地方,然后包含对象冻结其布局,而不会进一步更改值布局。

这种分离的空通道可以称为外部空通道。它将使用单独的硬件指令进行访问,从而导致一致性问题。

如果小心谨慎,大多数一致性问题都很容易避免,但有一个问题似乎很难避免,即竞争条件可能导致先前被 null 覆盖的值再次可见,即从覆盖中“复活”。这个问题可能非常严重,以至于在许多情况下,外部 null 通道不合适,除非它们可以合并到原子包装器中(见上文)。其他地方有关于并发效应的完整讨论,包括对 null 通道竞争的讨论。

当将变量设置为 null 时,不会发生争用,因为字节只是被置零。线程要么看到写入的零,要么看不到。无论哪种情况,值容器都不会改变。

如果我们尝试以某种方式重置值容器,则在将逻辑值设置为 null 时,我们会导致一些痛苦的竞争条件。所以,如果可以的话,我们不要进行额外的清除。

由于我们不重置容器,因此即使变量处于逻辑空状态,容器中也会存储一个过时的值。确保将来永远不会观察到此类过时值可能很重要。但是,将空通道设置为“非空”状态的竞赛可以(在精心构建的条件下)揭示先前覆盖的非空值。

引用堆的陈旧值可能会导致 GC 保留其本可以释放的存储空间。如果这是一个问题,我们要么避免对具有托管引用的值使用这些技术,要么要求 GC 通过清除陈旧引用来帮助我们(和它自己)。

当将变量设置为非空值时,可能会发生外部空通道上的竞争,因为有两个步骤可以分开,即写入值容器(非空值)和写入空通道(以表示“非空”状态)。

这些竞争可以看作是两种不同效果的组合。首先,容器本身存在竞争,就好像没有空通道一样。其次,空通道字节存在竞争。

第一种竞争与变量上的任何其他 Java 竞争没有什么不同,前提是容器完全一致。如果容器不完全一致,那么常见的逐字段撕裂可能会引入混合值。这与空通道活动无关。

空通道上的竞争是简单且单方面的。它们仅在写入非空值时发生。如果第二个线程正在写入非空值,则空通道上没有可见的竞争,但容器本身会竞争,如上所述。

如果第二个线程正在写入空值,则空通道上的竞赛获胜者将确定总体值是否为空。

写入非空值时,务必先将值写入容器,然后再更新空通道。否则,竞争读取器线程可能会见证非空状态,并拾取容器中的任何陈旧垃圾。这会使竞争变量看起来像是倒退了,甚至会暴露受保护的值状态,例如未初始化的值状态。

如果 GC 一直在悄悄地清除空容器中的引用,那么时间倒退的值将被严重破坏,并且 GC 会将零放置在奇怪的位置。

解决这个潜在问题的方法不仅是在断言空通道中的非空状态之前更新容器,而且还要在两个写入指令之间发出存储 - 存储屏障,以防止效果被重新排序。

在存储-存储屏障成本较高的平台上,在写入之前读取空通道可能是值得的,并且只有在状态错误时才更新它。但这种性能调整往往非常微妙且容易出错。

如果硬件支持快速的 store-with-or 指令,那么效果会更好,如果 or-ed 位已在内存中设置,该指令将变为 nop。英特尔有一个可能有效的 bts 指令。为了实现对称性,将空通道设置为“空存在”状态可以使用 store-with-and 指令来清除空通道位(英特尔 btc)。使用此类指令可能允许将八个空通道打包成一个字节。

可能值得进一步考虑让 GC 自动清除断言了空通道的容器中的引用。只有当我们能够证明那些 GC 修改的值永远不可观察时,这才是安全的,这虽然很棘手,但可行。要避免的是,竞争线程在 64 位部分被清除为占位符值的同时收回空通道。因此,写入操作对(存储有效负载、收回空通道)不能由 GC 安全点隔开,因为第二个线程可能会在安全点之前重新断言空通道,导致第一个线程从安全点返回,收回空通道,从而恢复被 GC 破坏的值。

请注意,Valhalla 允许类将其全零初始状态保持为私有。(外部用户必须通过构造函数,构造函数可以要求某处有一些非零位。)这是对 VM 引入的占位符非常谨慎的另一个原因。对于这样的类,不允许任何东西(甚至竞争条件)暴露这个全零初始状态。由于每个 VM 容器(至少在 HotSpot 中)都以全零状态开始,因此收回空通道的过程必须始终先存储有效的 64 位值。如果在损坏的 VM 实现中,线程在没有先写入有效的 64 位值的情况下收回空通道,并且另一个竞争线程立即读取容器,则第二个线程将看到未初始化的 64 个全零位,这可能是值类的无效值。

如果您可以证明对容器只有一次写入,但可能多次读取,则可以先存储正常值字段和空通道(缩回,非零值)。在这种情况下,似乎不需要存储-存储屏障。

如果有许多线程进行许多读取和许多写入,并且空通道未与正常值字段一致更新,则会发生争用,但只要小心谨慎,就不会出现新的争用,如上所述,注意存储存储屏障和安全点。

可能存在一种新的竞争:从一个最初为空的容器开始。现在假设一个线程 TX 将一个常规 64 位值 VX 存储到其中,但随后进入休眠状态很长时间(虚拟的一天),然后才开始收回空通道(在虚拟的早晨)。当第一个线程休眠时,另一个线程 TY 写入一个常规 64 位值 VY(立即收回空通道),然后 TY 也写入空值(断言空通道)。当 TX 休眠时,容器已准备好 VY 的位,然后设置为空。当 TX 唤醒并收回空通道时,出现的值是 VY。另一个线程可能会观察到值序列 VY、null、VY。最后一个值是复活的 VY,而不是 VX。如果容器被缓冲,正常的计算机内存操作将使序列 VY、null、VY 无法观察到,因为计算机内存不会复活已被覆盖的值。这种特殊类型的空值竞争在 Java 内存模型中合法吗?是的,除了易失性字段。某些 Java 程序会注意到这一点并发生故障吗?也许,最终会这样。

2.3.利用原语的松弛

如果某个类型自然地使用 64 位的容器表示,但该类型的可能值少于 2^64 个,则我们说该表示存在一些松弛。衡量松弛的一种方法是检查未使用值的数量除以 2^64(对于 64 位容器)的比率。这是表示位的随机配置无用的概率,因为它不代表预期类型的​​可能值。

我们不希望容器中存在太多空闲空间,但如果有哪怕一点点空闲空间,都可以将其转换为零通道。如果没有空闲空间,我们需要如上所述的外部零通道。

布尔值和托管引用是松弛的潜在来源。此外,高级用户可能通过对数字的类型限制来注入松弛(见下文)。大多数纯数字数据结构往往没有额外的松弛。有理数可能使用受限整数分母,拒绝存储零,从而让 VM 有机会重新利用该编码。

请注意,int 和 long 的松弛为零(当存储在其自然大小的字中时)。如果将所有 NaN 值视为等价,则 Float 和 double 的松弛量很小;但 Java VM 实际上会区分所有自然出现的 NaN 值,从而再次消除松弛。Java 布尔类型以 8 位字节排列,几乎全部松弛,为 99%(254/256)。

具有松弛的表示使用的存储空间比严格意义上的要多;更大的松弛意味着更多的存储空间浪费。为了“消除松弛”,可以尝试一些压缩编码,尽管在大多数情况下,编码和解码成本是不可接受的。

例如,一个字节可能存储在单个内存位中,与用于不相关目的的其他内存位存储在同一个字中。这样做的问题是安全地执行该位的更新,而不会破坏其他不相关的位;这通常需要对整个字执行 CAS 操作。有些 CPU 确实支持安全地设置和清除单个位,但这可能仍然比仅使用一个字节并承担存储命中率更尴尬。毕竟,所有现代 CPU 都支持快速单字节更新,即使它们不提供对较小更新的类似支持。

再举一个例子,以 4 位存储(BCD 形式)的单个十进制数字具有 38%(6/16)的松弛度,因为仅使用了 16 个表示值中的 10 个。如果将三个这样的数字联合编码(以 10 位形式),则松弛度较小,为 2%(24/1024)。

这里关于松弛的要点是,具有松弛的值容器还提供内置的空通道。只需选择一个不用于表示有效值的表示位模式;称之为 R。现在根据比较来定义空通道:如果容器位与 R 相等,则断言空通道。否则它将被撤回,并且位表示类的实际值。

当以这种方式使用 slack 时,所选的位模式 R 实际上必须为全零位(对于干净的 VM 工程而言)。这样,当 VM 容器刚刚分配并填充全零位时,这些位将表示此类 Java 变量的正确默认值,即 null。

这可能是一个问题,在 Java 字节的情况下很容易看到。可空字节(类型 java.lang.Byte)可以存储在 8 位容器中,但八个零位是什么意思?正如刚才解释的那样,它应该表示空。但是,由于布尔值 false 是零值(在每个 Java VM 中!),那么 false 应该如何表示。8 个零位的字节有两种可能的含义。正确的答案是扰乱可空字节容器,这样 true 和 false 都有非零表示。

至少有两种简单的方法可以进行这种扰动。您可以使用 XOR 将多个位中的一位设置为非零,以表示非空值,因此(例如)null、false、true 编码为 0、2、3,或者可能是 0、254、255。或者,您可以使用 ADD 将低值上移一位,以释放零,这样 null、false、true 编码为 0、1、2。

基于 ADD 的技术可能具有超越 XOR 的一些技术优势。例如,它更自然地允许嵌套的可空值“堆叠”。嵌套可空值的一个例子可能是 NullableBox,其中容器值 null、new NullableBox(null)、new NullableBox(0) 和 new NullableBox(1) 可以使用表示 0、1、2、3 进行编码。所有可能的值都可以容纳在 33 位中,也许可以四舍五入为 64 位。

此示例也可以看作需要一个空通道,该通道可以表示两个不同的空状态,即表示为 0 或 1 的状态,并且还可以表示完全非空状态 (new NullableBox(I))。这可以通过两位组合空通道字段来表示。更一般地,如果有 M 个空状态,则可以在 lg(M+1) 位中实现组合空通道。(如果有 7 个空状态,则 3 位字段将区分所有这些空状态以及完全非空状态。)此组合空通道的使用方式与简单的单比特空通道相同。所有相同的竞争条件都将适用;完全非空值将(在竞争观察中)与各种空值共存。正如 JMM 所要求的那样,每次读取都将正确对应于之前的某个写入。

NullableBox 需要两个额外的空通道,就像 NullableBox 一样。这要么是使用 ADD 技术的 65 位,要么是 2 位组合空通道。

注意:我们在这里对泛型的处理有些马虎。为了便于论证,我们假设 NullableBox 的布局以某种方式依赖于 T。目前没有虚拟机能够做到这一点。所有 Java 虚拟机都会将 T 抹去为 Object 或其他边界,因此这些使用 NullableBox 的合理示例实际上不起作用。要使所声称的表示技术成为可能,您必须首先手动将值类型专门化为 NullableBoxOfInteger 和 NullableBoxOfLong。总有一天,虚拟机会自动执行此操作。

2.4.利用托管指针的松弛

Java VM 管理的指针有很多余地,因为几乎可以肯定存在与实际对象不对应的原始指针值。随机选择的指针值可能指向对象的中间,或指向未用作 VM 堆的地址空间的一部分。VM 始终将指针值零保留为无效对象地址;对象的分配起始位置不低于地址 1,通常要高得多。

假设虚拟机要保留(比如说)一百个低地址,从零开始,并且从不将对象分配给这些地址。我们可以将非零保留地址称为准空地址,因为它们的行为与 null 非常相似,但它们与 null 本身不同。这些准空地址可以以规范的方式用于帮助为本身包含引用字段的值类实现空通道。上面的 NullableBox 示例可以重铸为 String 指针中的底部,即 NullableBox。在这种情况下,我们可以将 null、new NullableBox(null)、new NullableBox(“foo”) 表示为位模式 0、1(准空地址)和字符串“foo”的堆地址。

quasinull 策略实际上允许无限嵌套级别。当嵌套级别过多,并且 quasinull 用完时,您可以切换到老式的后备方法,即在单独的堆位置中缓冲相关值。但代码以这种方式使用甚至 10 个 quasinull 的情况很少见。

这里的要点是,如果值类包含指针字段,那么它的空通道可以隐藏在该指针字段产生的松弛中。如果指针字段不可空,那么整体全零表示(将每个字段置空或置零)毫无疑问是整体空值的表示(而不是值类实例)。如果指针字段可空(这可能更常见),那么使用准空值来表示该字段的空值可确保全零表示再次毫无疑问是空值表示。

加载可能包含准空值的值类实例需要将准空值替换为真空值(仅针对一个字段)。这有一定的成本,但可能比在某处管理单独的空值更改位更好。同样,将值类实例存储到可空容器中可能需要将真空值(在字段中)替换为准空值,以避免意外将存储的值误认为空值。

在具有对齐约束的 oop 表示中,准空值也可能使用高于所有 oop 的高值或特殊的未对齐值进行编码,这些约束要求低位为零(或其他固定值)。不过,此处的提议专门针对接近零的低数字。这些低数字似乎最容易快速区分(在关键 GC 路径中)与必须作为有效托管引用处理的地址模式。低标记位在抽象上尤其有吸引力,但在 GC 中的热路径中往往需要过多的位剥离操作。

2.5.将空通道拟合到数组中

有时,空通道必须存储在数组中,作为数组元素外部的字节。必须至少稍微修改数组的存储模式,以便为空通道字节腾出空间。它们可以全部放在数组主体末尾的块中,或放在数组主体的开头,或放在由引入数组头的指针指向的侧数组中。所有这些技术都受到空通道和值子字段之间距离的影响。实际上,当遍历这样的数组时,两倍的缓存行保持繁忙。

或者,我们可以放弃,只将数组元素的大小加倍,将每个其他元素最多分配一个字节的空通道。(因此,64 位值的数组将变成 128 位值的数组,每个元素浪费 7 个字节。)但这也会使遍历数组时活动缓存行的数量加倍。

一种更激进的技术是交替阻塞数组。这会将几个空通道打包在一起,然后是几个扁平容器,以 ABA 模式排列。这种数组的索引算法比常规同构数组更复杂,但它却出奇地简单。特别是,它不需要条件、分支、乘法或除法(除以 2 的幂之外)。这会破坏 VM 对 Java 数组的处理,而且我们处理数组的预算有限。

然而,已知原子包装器之外的外部空通道可能存在致命的竞争条件。也许 ABA 模式只在与软件交易结合时才有用。在这种情况下,锁定小部件(见下文)可能可以在 A 部分中找到空通道,而有效载荷则位于 B 部分。

3.格式簿记

假设您有一个存放值的堆容器的基地址,并且您还知道它的值类。为了将该值读入寄存器,您还需要什么其他信息?这个问题的答案为 VM 的一些较低层的设计提供了信息,因为它必须回答所有这些问题,并将它们记录为一个可以检索和使用的形式。写入值以及可能对其进行 CAS 处理也存在一个并行问题。初始化很容易,因为根据假设,堆的每一位都是从零开始的。我们将在这里集中精力阅读。

除了容器的位置及其逻辑类型(其值类)之外,下面是一些有用的信息列表:

  • 容器的大小是多少?
  • 其组成词的大小和对齐方式是什么?
  • 它是一个原子包装器吗?
    • 如果不是原子的,我们将使用松散一致的指令来加载
    • 如果是原子的,它是否使用原子硬件加载指令?
    • 否则,我们如何找到它的锁定小部件(用于软件事务)?
  • 其中的托管引用在哪里?(用于 GC 屏障的 oop-map…)
  • 是否有空通道字节,如果有,在哪里?
  • 或者,是否有一个值子字段是松弛的空通道?
    • 如果有,它在哪里?(暗示大小和对齐)
    • 以及,我们如何剥离空编码?(可能是条件递减)

对于任何合理的小值容器,所有这些信息似乎都可以简洁地总结出来。(这就是我们所需要的,因为我们倾向于直接缓冲不合理的大值,如上所述。)以下是位预算:

  • size: 5-7
  • align: 4
  • atomic: 1-2
  • oops: 5-9
  • byte NC: 5-10
  • subfield NC: 5-7
  • NC strip: 0-2

这样一来,总范围为 25-41 位,作为不相交的位域,很容易放入 64 位机器字中。如果稍后添加其他激进技术,似乎还有大量额外空间来表示它们。出于这个原因,似乎可以组织 VM 以在 64 位 C++ 类中传递“值格式”描述符,以供 VM 自己记账。它还可以将它们作为原始 64 位长整型通过需要进行并行记账的最低级 JDK 代码进行线程化,以便正确形成 VarHandles。

4.128位机器字

如果 VM 主机硬件支持 128 位原子内存操作,则上述所有讨论均可应用于向其值自然为 128 位的类添加空通道的问题。

也就是说,在所有这些考虑因素中,数字 64 可以统一替换为 128。我们是否要这样做是一个需要通过基准测试来解决的问题,因为当今的 CPU 处理 64 位和 128 位值的方式非常不同。在某些 CPU 上,128 位数据处理和内存访问可能会产生大量额外成本。

如果 ISA 支持寄存器对操作(ARM 支持但 x86 不支持),则实现可能会对原子 128 位内存访问征收相对较低的成本。

另一方面,如果支持 128 位原子内存访问,但仅通过向量寄存器单元(如 x86 所做的那样),则组装和拆卸 128 位内存传输的成本可能会使 128 位原子在许多用例中过于昂贵。当然,将 128 位结构从一个地方复制到另一个地方可能非常快,这对于复制来说很好。但如果数据处理需要位出现在通用寄存器文件(ALU)中,那么将它们发送到 VPU(向量单元)和从 VPU(向量单元)发送可能会感觉像慢速邮政服务。

关于 128 位的相同观点也适用于更大的块。例如,如果未来的 CPU 能够在缓存行级别(例如 512 位)提供原子内存操作,则 VM 将能够更轻松地原子地展平更大的值。但它仍然需要特别处理零通道奇怪的不对称需求。

当然,用户更愿意根据一些高级、可移植的经验法则来设计任何合理大小的数据结构。但这些数据结构的性能取决于硬件的原子性特征。目前这似乎是不可避免的。随着时间的推移,VM 优化技术的进一步发展和/或硬件供应商提供的额外原子性支持应该会使用户越来越感觉不到依赖性问题。

5.交替阻塞阵列

交替分块阵列(ABA)是一种可能的阵列结构,它由两个(或更多)固定大小的子阵列“A”和“B”以重复交替的模式组成。

ABA 可以密集且连续地支持具有大小差异很大的组件的值,或具有外部空通道的值。以下是具有空通道的扁平化 Long 数组的伪代码:

a: (header)
a.length: int
a.BLK = 4
repeat[ceil(length/BLK)] {
  NC: byte[BLK]
  _padding: byte[sizeof(long)-BLK]
  value: long[BLK]
}

字节 A 和长 B 的整体模式是 AAAA BBBB,重复直到有足够的 B。每个重复块分为 A 和 B 子块,A 是 4 个 1 字节空通道,B 是 4 个 8 字节长。因此,每个重复块的大小为 40 字节(包括填充)。每个空通道 (NC) 都位于其平坦值的一个 64 字节缓存行内。块大小也可以是 8 个项目,这将删除填充,但将空通道拉得离其值稍远一些。

如果数组元素中的值本身具有不同的子字段,我们可以考虑将它们熔化并重新组织为 4 个块(或我们喜欢的任何块因子)。这将导致交替阵列图案化为 AAAA BBBB CCCC … AAAA BBBB CCCC …,其中 A 项可能是空通道,而 B/C/… 项是各种大小的子字段,融合后重新加入子块中。

这种交替布局可以使相关数据更接近,即使数组元素是由多种内存格式构建的。重复的分块模式应始终在几个连续的缓存行中实例化。较长的数组会多次重复分块模式。可以在最后一个子块的最后一个活动元素之后切断对象。

当 Java 代码对数组的某些部分执行顺序访问时,现代内存硬件可以很好地为其提供服务,因为它通过预取连续的缓存行来满足这种流式访问的需求。

交替数组的索引算法出奇地简单。每个有效地址会多花费几个周期,因此它不是免费的,但它足够简单,以至于它的延迟有时可以被内存或处理延迟所掩盖。

array blocked as {byte[R] u[N]; byte[P] _; byte[S] v[N];}
size of each block = ((R+S)*N+P)
typically R,S,N are powers of 2
example: R=1,P=0,S=8,N=8 block size/align = 72/8

U(i;N;R;S) = offset of a[i].u control data
 = let j=floor(i/N) in j*(P+S*N) + i*R
V(i;N;R;S) = offset of a[i].v payload data
 = let j=floor(i/N) in (j+1)*(R*N+P) + i*S

以下是一个直观的解释,并附带一个示例:任何数组中任何项目的偏移量都等于数组中所有前一个项目的总大小。(无论数组是否是同构的,情况都是如此。)具体来说,假设我们的块为 8 个 A 字节和 8 个 B 长整型。对于某个逻辑索引 i 处的任何 A 或 B,项目前面的整个 AB 块的数量为 i/8,向下舍入(⌊i/8⌋ 或 floor(i/8))。这些块的总大小为 ⌊i/8⌋×72(72 为 8 个字节和 8 个长整型的大小)。在当前块中,逻辑 A[i] 之前的 A 项目数量只是余数 i%8,逻辑 B[i] 之前的 A 和 B 项目数量为 8+(i%8)8(或 8 个字节,然后是 0-7 个长整型)。使用恒等式 i%8 = i - ⌊i/8⌋×8,我们可以删除 i%8 个项并将索引表达式折叠为 i 和 ⌊i/8⌋ 中的项的线性组合。或者,您可以注意到任何给定的 A[i] 前面总是有 i 个 As,每个 B[i] 前面都有 i 个 Bs;这会让您得出相同的结论。在本例中,逻辑元素的偏移量为 OFF A[i] = i+j64 和 OFF B[i] = (i+j+1)*8,其中 j=⌊i/8⌋ 是整个前导 AB 块数量的公共子表达式。计算该值 j 需要一条指令,而重新缩放(64)则需要第二条指令。此时它是有效地址中的加数,可能需要第三条指令。

如果希望所有 BBBB 共享 A 中的公共字或字节,则交替结构可以是 A BBBB。例如,A 可以是空通道的 32 位位掩码,后面是共享该字的 32 个 B,作为位掩码。或者 A 可以是某种锁定小部件,这样对所有 B 的访问都通过沿阵列条带化(拆分)的锁来处理,该锁位于同一缓存行范围内。

6.使用不常见的位来控制受影响值

假设我们某天早上醒来,决定将 65 位强制放入 64 位容器中,以制作完全硬件原子可空的 Long。

这里我们可以施展一个疯狂的技巧。它基于这样一个事实:尽管我们的容器是无松弛的,但我们可能会选择一个或两个非常不常见的值(在 2^64 中),并且永远不会设置这些值。它们将被称为牺牲值,因为如果容器被要求保存牺牲值,那么有人将不得不支付延期成本。

首先,选择一个非常随机(难以预测)的 64 位值。这将是我们的第一个受害者值。我们还通过翻转低位 VV^1 从第一个受影响者值中得出第二个受影响者值。两个受影响者都不会直接出现在容器中。(但愿我们永远不会看到它!)

接下来,安排容器的访问方法在读取或写入非空值时对第一个受影响者值进行异或。

写入 null 时,我们将零写入容器。整个容器现在是一个 64 位 null 通道。读取时,我们首先检查是否为 null(零)或 1(对于第二个受影响者值)。

如果我们从未看到 1,那么我们可以读取和写入 null 以及除两个受影响值之外的任何 64 位值。

如果我们运气不好,有人试图存储受影响者值,会发生什么?好吧,我们存储一个 1(它将解码为第二个受影响者值)。然后我们走一条慢路,并在某个巨大的侧表中(在云中!!)懒惰地分配一个位,该位告诉我们存储了哪个受影响者值。

总结一下访问读方法:

  • read bits X
  • if X=0 we read a null
  • if X=1, take slow path and look up a bit B, set X=B
  • return X^VV

写访问方法反转这些步骤,并且如果存储了任一牺牲值则注意定义位 B。

与 C2 不常见陷阱类似,此位 B 可称为“不常见位”。其理念是,你希望自己不需要它,但当你需要它时,你已准备好争先恐后地分配它。

如果数据结构有多个不常见位,那么将不常见位的逻辑或存储为数据结构中的单个布尔值可能是有意义的。这样,只要没有设置不常见位,就可以通过快速检查一个额外的本地布尔值轻松排除任何和所有不常见位。但使用双受害者模式似乎更好,其中容器中的全零值是我们希望使其罕见的情况。然后通常只有一个值需要加载。

7.拉伸一个不常见的部分

单个不常见位也可以在许多 64 位容器之间共享,甚至数百万个,以控制所有可能的额外空值条件。如果未设置不常见位,则所有容器都按字面读取,并且没有空值(它们都没有那个尴尬的 2^64+1st 值)。如果设置了不常见位,则第一个容器包含的不是其自然有效负载,而是第一个具有空值的容器的索引,并且其符号位对级联不常见位进行编码,以控制剩余容器中第二个空值的存在。(您有 63 位来编码哪个是包含不常见空值的容器,这已经足够了;我们假设是 16 位。)读取第一个容器的访问方法是:

  • load the first container value N=C[0], and extract I=N&0xFFFF
  • if the uncommon bit is clear, return N
  • otherwise, if I=0 return null
  • otherwise, return C[I]

写入 C[0] 或读取或写入其他容器的访问方法复杂得令人难以操作。但此示例表明,原则上,如果您愿意进行大量压缩类计算,则只需很少的外部状态即可将单个额外值注入容器。一个不常见位最多可由 2^63 个可空容器共享,这意味着每个容器只需要该位的一小部分即可编码空值的存在。小数位的想法可能看起来很奇怪,但它是信息理论的合法工具。

8.使用部分指针来框出不常见的值

HotSpot 的一个老想法(甚至被原型化过一两次)是支持一种混合容器,这种容器有时可以容纳 64 位数字,有时可以容纳托管引用。这个想法是,将一些较大的 64 位值子范围分配给托管指针,甚至可能是这些指针的实际位模式,而其他完全超出范围的值则被解释为长值。分配用于表示托管指针的长值称为“排除值”(从长类型的角度来看)。

这个容器被称为部分指针,因为其值有时是一个指针。其他时候它是一个长整数。

Mozilla JavaScript 引擎采用了一种类似的策略,称为 NaN 装箱。那里的基本类型是 double,托管指针全部隐藏为非标准 NaN 值。一个正常的 NaN 值保留其浮点身份。

这是草图。首先,设置一个参数 N<64,这是托管指针所需的动态范围的位数。也许 N=63?然后,定义 long 的类型限制,省略从零到 2^N-1 范围内的所有值。如果容器包含任何这样的值,则它就是托管指针。请注意,容器自然会将自身初始化为零,即空托管指针。

你如何使用这个东西?你需要编写访问方法,根据状态执行正确的操作。对于读:

  • 如果容器小于 2^N(无符号),则返回对象或 null。
  • 否则返回 long。

对于写:

  • 如果写入的值是长整型,且为 2^N 或更大(无符号),则将其存储。
  • 如果是排除的长整型值,则将其装箱(!!)并存储为引用。
  • 如果是引用(可能是装箱的长整型),则将其存储为引用。

如果长值的用户抱怨所有好值(如 0、1、2)都被排除在外,请教他们添加偏移量或异或模式以将排除的区域移到其他地方。随机选择的“受害者模式”,添加/减去或异或到传入和传出的长值中,将倾向于使容器不太可能必须装箱任何值。不太可能,我的意思是概率 1/2^(64-N)。这意味着它并非完全不可能。

9.使用类型限制将松弛强制放入容器中

强制松弛的更文明的方法是使用类型限制,禁止逻辑变量包含零值。例如,类型限制为非零的 Long 可以使用 64 位零作为空通道。

这需要人工干预,但有一天可能会成为一种有用的编程技术,当也需要空通道时,帮助 VM 更有效地平坦化限制值。

另一方面,可以在用户代码中存储无空值,并在读取和写入时手动将零与空值交换。

类型限制也可能排除非零值(例如“所有负数”)。在这种情况下,可以使用 XOR 技巧来确保逻辑零不会物理存储为零位,并且另一个禁止值(例如 -1)被异或到所有传入和传出值中。

10.DCAS 连接

关于内存设计的讨论由来已久,有人建议在两个单独的字上构建 CAS(“DCAS”)可能是一件好事。如果是这样,也许它可以用于在值被融合后访问值的不相交部分,或者访问值及其不相交的空通道。

这不太可能有帮助,原因有两个。首先,目前似乎没有一系列 DCAS 提案。其次,任何类型的 CAS 都慢得令人沮丧。

11.商业可行性

现在,英特尔的 TSX 似乎已被弃用,因此似乎没有神奇的高性能硬件事务机制。也许有一种出色的软件事务机制?我们只需要其中之一,就可以让我们在访问方法中发出多个读取(或写入)指令,同时仍保持完全一致性。

  • 20
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
智慧校园整体解决方案是响应国家教育信息化政策,结合教育改革和技术创新的产物。该方案以物联网、大数据、人工智能和移动互联技术为基础,旨在打造一个安全、高效、互动且环保的教育环境。方案强调从数字化校园向智慧校园的转变,通过自动数据采集、智能分析和按需服务,实现校园业务的智能化管理。 方案的总体设计原则包括应用至上、分层设计和互联互通,确保系统能够满足不同用户角色的需求,并实现数据和资源的整合与共享。框架设计涵盖了校园安全、管理、教学、环境等多个方面,构建了一个全面的校园应用生态系统。这包括智慧安全系统、校园身份识别、智能排课及选课系统、智慧学习系统、精品录播教室方案等,以支持个性化学习和教学评估。 建设内容突出了智慧安全和智慧管理的重要性。智慧安全管理通过分布式录播系统和紧急预案一键启动功能,增强校园安全预警和事件响应能力。智慧管理系统则利用物联网技术,实现人员和设备的智能管理,提高校园运营效率。 智慧教学部分,方案提供了智慧学习系统和精品录播教室方案,支持专业级学习硬件和智能化网络管理,促进个性化学习和教学资源的高效利用。同时,教学质量评估中心和资源应用平台的建设,旨在提升教学评估的科学性和教育资源的共享性。 智慧环境建设则侧重于基于物联网的设备管理,通过智慧教室管理系统实现教室环境的智能控制和能效管理,打造绿色、节能的校园环境。电子班牌和校园信息发布系统的建设,将作为智慧校园的核心和入口,提供教务、一卡通、图书馆等系统的集成信息。 总体而言,智慧校园整体解决方案通过集成先进技术,不仅提升了校园的信息化水平,而且优化了教学和管理流程,为学生、教师和家长提供了更加便捷、个性化的教育体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值