文档章节

Parquet

水东流
 水东流
发布于 2016/07/09 08:16
字数 5375
阅读 138
收藏 0

Google 对于传说中3秒查询 1 PB 数据的 Dremel,有一篇论文:Dremel: Interactive Analysis of Web-Scale Datasets http://research.google.com/pubs/pub36632.html.  这篇论文基本上在描述 Dremel 的数据存储格式.

用容易理解但不准确的的话概括上面那篇论文,就是怎么把一些嵌套的 Protobuff 结构(有相同 schema,如果你不熟悉 Protobuff,那类比 xml 或者 json),拆成若干个表存储(就是逻辑上的二维表),然后通过查那些表,还能快速拼装回原来的 PB(指 Protobuff 下同),再而且,如果你只关注嵌套结构中的某一个层级的某一部分,我可以只读那一部分的数据,只把你关心的那一部分拼装回来,所谓指哪打哪,由于不用读其他不必要的部分,所以省掉了很多 IO,所以速度很快.  然而由于我很笨,所以一直感觉看的云里雾里,直到 2013年9月11号,Twitter 的 Engineering blog 发了一篇博客叫 Dremel made simple with Parquet,看过后恍然大悟. 以下就翻译这篇博客,算是对自己阅读的总结,也与更多人分享.


对于优化『关系型数据库上的分析任务』,列式存储(Columnar  Storage)是个比较流行的技术.  这一技术对处理大数据集的好处是有据可查的,可以参见诸多学术资料,以及一些用作分析的商业数据库.(http://people.csail.mit.edu/tdanford/6830papers/stonebraker-cstore.pdf, http://www.vldb.org/pvldb/,http://www.monetdb.org/)

我们的目标是,对于一个查询,尽量只读取对这个查询有用的数据,以此来让磁盘 IO 最小.  用 Parquet,我们做到了把 Twitter 的大数据集上的 IO 缩减到原来的 1/3.  我们也做到了『指哪打哪』,也就是遍历(scan)一个数据集的时候,如果只读取部分列,那么读取时间也相应会缩短,时间缩短的比例就是那几列的数据量占全部列数据量的比例.  原理很简单,就是不采用传统的按行存储,而是连续存储一列的数据. 如果数据是扁平的(比如二维表形式),那列改成按列存储毫无难度,处理嵌套的数据结构才是真正的挑战.

我们的开源项目 Parquet 是 Hadoop 上的一种支持列式存储文件格式,起初只是 Twitter 和 Coudera 在合作开发,发展到现在已经有包括 Criteo公司 在内的许多其他贡献者了. Parquet 用 Dremel 的论文中描述的方式,把嵌套结构存储成扁平格式. 由于受益于这种技术,我们决定写篇更通俗易懂的文章来向大家介绍它. 首先讲一下嵌套数据结构的一般模型,然后会解释为什么这个模型可以被一坨扁平的列(columns)所描述,最后讨论为什么列式是高效的.

何谓列式存储?看下面的例子,这就是三个列 A B C.

4b463c3f136464a93ad5d5e023f7a53b

如果把它换成行式存储的,那么数据就是一行挨着一行存储的

061f34650df99d004b0d55886de89d05

按列存,有几个好处:

  • 按列存,能够更好地压缩数据,因为一列的数据一般都是同质的(homogenous). 对于hadoop集群来说,空间节省非常可观.
  • I/O 会大大减少,因为扫描(遍历/scan)的时候,可以只读其中部分列. 而且由于数据压缩的更好的缘故,IO所需带宽也会减小.
  • 由于每列存的数据类型是相同的,we can use encodings better suited to the modern processors’ pipeline by making instruction branching more predictable. (没想好怎么翻译,各位自己理解吧)

嵌套结构的模型

首先是嵌套结构的模型,此处选取的模型就跟 PB 类似. 多个 field 可以形成一个 group,一个 field 可以重复出现(叫做 repeated field),这样就简单地描述了嵌套和重复,没有必要用更复杂的结构如 Map / List / Sets,因为这些都能用 group 和 repeated field 的各种组合来描述. (熟悉 PB 的人,对这里说的东西应该很清楚,因为这就是跟 PB 一样的,如果此处有疑惑,最好的方法是立即左转出门去看一下 PB)

整个结构是从最外层一个 message 开始的. 每个 field 有三个属性:repetition、type、name. 一个 field 的 type 属性,要么是 group,要么是基本类型(int, float, boolean, string),repetition 属性,有以下三种:

  • required:出现,且只能出现 1 次.
  • 出现 1 或 0 次.
  • repeated:0 到 任意多次

例如,下边是一个 address book 的 schema.

message AddressBook {
  required string owner;
  repeated string ownerPhoneNumbers;
  repeated group contacts {
    required string name;
    optional string phoneNumber;
  }
}

Lists(或者 Sets)可以用 repeated field 表示.

d30ed6cd613b548076bd485bbc14191a

Maps,首先有一个 repeated field 在外面,里面每个 field,是一个 group,group 里面是 key-value 对,其中key 是 required 的.

0923a5c53ef576504134ed0169120d06

列式存储格式

列式存储,简单来说就是三件事:1. 把一个嵌套的结构,映射为若干列  2. 把一条嵌套的数据,写入这些列里. 3. 还能根据这些列,把原来的嵌套结构拼出来. 做到这三点,目的就达到了.

译注:直观来看,嵌套结构含有两种信息:1. 字段的嵌套关系 2. 最终每个字段的值. 所以如何转换成列式也可以从这里下手,分别解决『值』和『嵌套关系』.

Parquet 的做法是,为嵌套结构的 schema 中每个基本类型的 field,建立一个列. 若用一棵树描述schema,基本类型的 field,就是树的叶子.

上边的 address book 结构用树表示:

3017b6d50e1c34840c431b63ab8687c8

观察上图,其实最终的值,都是在基本类型的 field 中的,group 类型的 field 本身不含有值,是基本类型组合起来的.

对上图蓝色叶子节点,每个对应一个列,就可以把结构中所有的值存起来了,如下表.

c488b45d59f921c64c8306d7e50dd69a

现在,『值』的问题解决了,还剩『嵌套关系』,这种关系,用叫做 repetition level 和 definition level 的两个值描述. 有了这俩值,就可以把原来的嵌套结构完全还原出来,下文将详细讲解这两个值到底是什么. ]

Definition Level

( 这俩 Level 容易把人看糊涂,如果看文字描述没明白,请看例子回头再看文字描述)

为支持嵌套结构,我们需要知道一个 field,到哪一层,变成 null 了(就是指field没有定义),这就是 definition level 的功能.  设想,如果一个field 有定义,则它的parents 也肯定有定义,这是很显然的. 如果一个 field 是没有定义的,那有可能它的上级是没定义的,但上上级有定义;也有可能是它的上级 和 上上级都没定义,所以需要知道到底是从哪一级开始没定义的,这是还原整条记录所必须知道的.

译注:(假设有一种一旦出现就每代必须遗传的病)如果你得了这个病,那么有可能你是第一个,你爸爸没这个病; 也可能是从你爸爸开始才出现这种病的(你爷爷还没这种病);  也有可能是从你爷爷开始就已经得病了.  反过来,如果你爸爸没这个病,那么你爷爷肯定也是健康的.  你需要一个值,描述是从你家第几代开始得病的,这个值就类似 definition level. 希望这比喻有助于理解.

对于扁平结构(就是没有任何嵌套),optional field 可以用一个 bit 来表示是否有定义: 有:1, 无:0 .

对于嵌套结构,我们可以给每一级的 optional field 都加一个 bit 来记录是否有定义,但其实没有必要,因为如上一段所说,因为嵌套的特性上层没定义,那下层当然也是没定义的,所以只要知道从哪一级开始没定义就可以了.

最后,required field 因为总是有定义的,所以不需要 definition level.

还是看例子,下边是一个简单的嵌套的schema:

message ExampleDefinitionLevel {
  optional group a {
    optional group b {
      optional string c;
    }
  }
}

转换成列式,它只有一列 a.b.c,所有 field 都是 optional 的,都可能是 null. 如果 c 有定义,那么 a b 作为它的上层,也将是有定义的.  当 c 是 null 时候,可能是因为它的某一级 parent 为 null 才导致 c 是 null 的,这时为了记录嵌套结构的状况,我们就需要保存最先出现 null 的那一层的深度了. 一共三个嵌套的 optional field,所以最大 definition level 是 3.

以下是各种情形下,a.b.c 的 definiton level:

f1d4bc6c968b20c3f7a65e35503af64e

fb745d043166a81830b585bd6b72d425

这里 definition level 不会大于3,等于 3 的时候,表示 c 有定义; 等于 0,1,2 的时候,指明了 null 出现的层级.

required 总是有定义的,所以不需要 definition level. 下面把 b 改成 required,看看情况如何.

message ExampleDefinitionLevel {
  optional group a {
required group b {
      optional string c;
    }
  }
}

现在最大的 definition level 是 2,因为 b 不需要 definition level. 下面是各种情形下,a.b.c 的 definition level:

ab3126ecd930394e41f5a1531820e4f5

不要让 definition level 太大,这很重要,目标是所用的比特越少越好(后面会说)

Repetition level

对于一个带 repeated field 的结构,转成列式表示后,一列可能有多个值,这些值的一部分是一坨里的,另一部分可能是另一坨里的,但一条记录的全部列都放在一列里,傻傻分不清楚,所以需要一个值来区分怎么分成不同的坨. 这个值就是 repetition level:对于列中的一个值,它告诉我这个值,是在哪个层级上,发生重复的.  这句话不太好理解,还是看例子吧.

2eb86bffb7000fa12279b27c3ae78ae2

这个结构转成列式的,实际也只有一列: level1.level2,这一列的各个值,对应的 repeatiton level 如下:

8d7afc3f1773b5c406ce78cb73556a7f

为了表述方便,称在一个嵌套结构里,一个 repeated field 连续出现的一组值为一个 List(只是为了描述方便),比如 a,b,c 是一个 level2 List, d,e,f,g 是一个level2 List,h 是一个level2 List,i,j 是一个level2 List。a,b,c,d,e,f,g 所在的两个 level2 list 是同一个 level1 List 里的,h,i,j 所在的两个 level2 List 是同一个 level1 List里的。

那么:repetition level 标示着新 List 出现的层级:

  • 0 表示整条记录的开始,此时应该创建新的 level1 List 和 level2 List
  • 1 表示 level1 List 的开始,此时应该创建一个 level2 List
  • 2 表示 level2 List中新的值产生,此时不新建 List,只在 List 里插入新值.

下图可以看出,换句话说就是 repetition level 告诉我们,在从列式表达,还原嵌套结构的时候,是在哪一级插入新值的.

55bdadbcd530bfde58b3c1328c3cec16

repetiton = 0,标志着一整条新 record 的开始.  在扁平化结构里,没有 repetition 所以 repetition level 总是 0.   Only levels that are repeated need a Repetition level: optional 和 required 永远也不会重复,在计算 repetition level 的时候,可将其跳过.

拆分与组装

message AddressBook {
  required string owner;
  repeated string ownerPhoneNumbers;
  repeated group contacts {
    required string name;
    optional string phoneNumber;
  }
}

现在我们同时用这两种标识(definition level, repetition level),重新考虑 Address book 的例子. 下表显示了每一列 两种标识可能出现的最大值,并解释了为什么要比列所在深度小.

b5634952a7f05109eb3fbb738ea09734

单说 contacts.phoneNumber 这一列,如果 手机号有定义,则 definition level 达到最大即2,如果有一个联系人是没有手机号的,则 definition level是 1. 如果联系人是空的,则 definition level 是0.

AddressBook {
  owner: "Julien Le Dem",
  ownerPhoneNumbers: "555 123 4567",
  ownerPhoneNumbers: "555 666 1337",
  contacts: {
    name: "Dmitriy Ryaboy",
    phoneNumber: "555 987 6543",
  },
  contacts: {
    name: "Chris Aniszczyk"
  }
}
AddressBook {
  owner: "A. Nonymous"
}

现在我们拿 contacts.phoneNumber 这一列来做说明.

若一条记录是如下这样的:

AddressBook {
  contacts: {
    phoneNumber: "555 987 6543"
  }
  contacts: {
  }
}
AddressBook {
}

转成列式之后,列中存储的东西应该是这样的(R = Repetiton Level, D = Definition Level):

4656c052cd5f18c21781b5ec39a53d24

为了将这条嵌套结构的 record 转换成列式,我们把这个 record 整个遍历一次,

  • contacts.phoneNumber: “555 987 6543”
    • new record: R = 0
    • value is defined: D = maximum (2)
  • contacts.phoneNumber: null
    • repeated contacts: R = 1
    • only defined up to contacts: D = 1
  • contacts: null
    • new record: R = 0
    • only defined up to AddressBook: D = 0

最后列中存储的东西是:

351de516e064ff3ea9668794a6e5fbb3

注意,NULL 值在这里列出来,是为了表述清晰,但是实际上是不会存储的.  列中小于最大 definition 值的(这个例子里最大值是2),都应该是 NULL.

为了通过列是存储,还原重建这条嵌套结构的记录,写一个循环读列中的值,

  • R=0, D=2, Value = “555 987 6543”:
    • R = 0 这是一个新的 record. 从根开始按照schema 重建结构,直到 repetition level 达到 2
    • D = 2 是最大值,值是有定义的,所以此时将值插入.
  • R=1, D=1:
    • R = 1  level1 的 contact list 中一条新记录
    • D = 1  contacts 有定义,但 phoneNumber 没定义,所建一个空的 contacts 即可.
  • R=0, D=0:
    • R = 0 一条新 record. 可以重建嵌套结构,直到达到 definition level 的值.
    • D = 0 => contacts 是 null,所以最后拼装出来的是一个空的 Address Book

高效存储 Definition Levels 和 Repetiton Levels.

在存储方面,问题很容易归结为:每一个基本类型的列,都要创建三个子列(R, D, Value). 然而,得益于我们所采用的这种列式的格式,三个子列的总开销其实并不大. 因为两种 Levels的最大值,是由 schema 的深度决定的,并且通常只用几个 bit 就够用了(1个bit 就可表达1层嵌套,2个bit就可以表达3层嵌套了,3个bit就能够表达7层嵌套了, [ 译注:四层嵌套编程的时候就已经很恶心了,从编程和可维护角度,也不应该搞的嵌套层次太深(个人观点) ]),对于上面的 AddressBook 实例,owner这一列,深度为1,contacts.name 深度为2,而这个表达能力已经很强了. R level 和 D level 的下限 总是0,上限总是列的深度. 如果一个 field 不是 repeated 的,就更好了,可以不需要 repetition level,而 required field 则不需要 definition level,这降低了两种 level 的上限.

考虑特殊情况,所有 field 全是 required(相当于SQL 中的NOT NULL),repetition level 和 definition level 就完全不需要了(总是0,所以不需要存储),直接存值就ok了. 如果我们要同时支持存储扁平结构,那么两种 level也是一样不需要存储空间的.

由于以上这些特性,我们可以找到一种结合 Run Length Encoding 和 bit packing(https://github.com/Parquet/parquet-mr/tree/master/parquet-column/src/main/java/parquet/column/values/rle) 的高效的编码方式.  一个很多值为 NULL 的稀疏的列,压缩后几乎不怎么占空间,与此相似,一个几乎总是有值的 optional 列,will cost very little overhead to store millions of 1s(在这个也没想好怎么翻译,总之是开销很小的意思了). 现实状况是,用于存储 levels 的空间,可以忽略不计. 以存储一个扁平结构为例(没有嵌套),直接顺序地把一列的值写入,如果某个field是 optional 的,那就取一位用来标识是否为 null.

Parquet文件的存储格式

那么如何把内存中每个AddressBook对象按照列式存储格式存储下来呢?

在Parquet格式的存储中,一个schema的树结构有几个叶子节点,实际的存储中就会有多少column。例如上面这个schema的数据存储实际上有四个column,如图4所示。

图4 AddressBook实际存储的列

Parquet文件在磁盘上的分布情况如图5所示。所有的数据被水平切分成Row group,一个Row group包含这个Row group对应的区间内的所有列的column chunk。一个column chunk负责存储某一列的数据,这些数据是这一列的Repetition levels, Definition levels和values(详见后文)。一个column chunk是由Page组成的,Page是压缩和编码的单元,对数据模型来说是透明的。一个Parquet文件最后是Footer,存储了文件的元数据信息和统计信息。Row group是数据读写时候的缓存单元,所以推荐设置较大的Row group从而带来较大的并行度,当然也需要较大的内存空间作为代价。一般情况下推荐配置一个Row group大小1G,一个HDFS块大小1G,一个HDFS文件只含有一个块。

图5 Parquet文件格式在磁盘的分布

拿我们的这个schema为例,在任何一个Row group内,会顺序存储四个column chunk。这四个column都是string类型。这个时候Parquet就需要把内存中的AddressBook对象映射到四个string类型的column中。如果读取磁盘上的4个column要能够恢复出AddressBook对象。这就用到了我们前面提到的 “record shredding and assembly algorithm”

 

The striping and assembly algorithms from the Dremel paper

To understand the examples given in the Dremel paper it is useful to annotate the schema with the corresponding definition level and repetition level. See example figure 2 and 3 in the paper

Nested schema to columns

The paper uses the following schema as an example:

message Document {
  required int64 DocId;
  optional group Links {
    repeated int64 Backward;
    repeated int64 Forward; }
  repeated group Name {
    repeated group Language {
      required string Code;
      optional string Country; }
    optional string Url; }}

The schema can be seen as a tree whose leaves are primitive types. A column is created for each leaf. So for this example we end up with 6 columns:

DocId
Links.Backward
Links.Forward
Name.Language.Code
Name.Language.Country
Name.Url

As some of the fields are repeated fields, we need extra pieces of information to be stored along with the data to allow re-assembling the records together. That's what repetition and definition levels do.

Column striping algorithm:

You can picture serializing a record as (depth-first) traversing a tree. Starting from the root of the record, follow down the first child until you reach a leaf. Children are either a different field or a different instance of a repeated field. When a leaf is reached write out the value for the corresponding column with the maximum definition level for this column (meaning it is defined) and the current repetition level (initially 0 as we start at the root). When an empty field is reached, write no value along with the definition level for the last defined level for all leaf nodes of the type bellow this node (and the current repetition level as well). When going back up the tree to find the next branch to follow down, the level where the last repetition was is the new current repetition level.

When a field is not defined, the definition level will be smaller than the definition level of this field.

Record assembly

Let's say you want to reconstruct the record but need to access only one of the fields. Re-assembling the record is doing the same traversal of the tree over again. Pick the column for the field you need and reconstruct the record as follows: When a definition level is lower than the max stop going down at this level, once a leaf as been reached go down to the repetition level and start going down from there with the next value.

Repetition and definition levels

A detailed explanation of levels can be found in this blog post

One important thing to remember to understand the examples is that not every level of the tree needs a new definition or repetition level. Only repeated fields increment the repetition level, only non-required fields increment the definition level. As those levels are very small bounded values they can be encoded efficiently using a few bits.

Required fields are always defined and do not need a definition level. Non repeated fields do not need a repetition level.

Here is the schema annotated with the actual levels to help follow along the example in the paper: Schema annotated

Because the repetition and definition levels are bound by the depth of the schema and are small integers. They can be stored in only a few bits. A flat data structure with all required fields uses no extra bits to store the levels which are always zero. An optional field requires one extra bit to store zero if it is NULL and one if it is defined. NULL values do not need to be stored as the definition level captures this information.

Dremel paper

Step by step:

R1

DocId: 10
Links
  Forward: 20
  Forward: 40
  Forward: 60
Name
  Language
    Code: 'en-us'
    Country: 'us'
  Language
    Code: 'en'
  Url: 'http://A'
Name
  Url: 'http://B'
Name
  Language
    Code: 'en-gb'
    Country: 'gb'

outputs:

R = 0 (current repetition level)
DocId: 10, R:0, D:0
Links.Backward: NULL, R:0, D:1 (no value defined so D < 2)
Links.Forward: 20, R:0, D:2
R = 1 (we are repeating 'Links.Forward' of level 1)
Links.Forward: 40, R:1, D:2
R = 1 (we are repeating 'Links.Forward' again of level 1)
Links.Forward: 60, R:1, D:2
Back to the root level: R=0
Name.Language.Code: en-us, R:0, D:2
Name.Language.Country: us, R:0, D:3
R = 2 (we are repeating 'Name.Language' of level 2)
Name.Language.Code: en, R:2, D:2
Name.Language.Country: NULL, R:2, D:2 (no value defined so D < 3)
Name.Url: http://A, R:0, D:2
R = 1 (we are repeating 'Name' of level 1)
Name.Language.Code: NULL, R:1, D:1 (Only Name is defined so D = 1)
Name.Language.Country: NULL, R:1, D:1
Name.Url: http://B, R:1, D:2
R = 1 (we are repeating 'Name' again of level 1)
Name.Language.Code: en-gb, R:1, D:2
Name.Language.Country: gb, R:1, D:3
Name.Url: NULL, R:1, D:1

R2

DocId: 20
Links
  Backward: 10
  Backward: 30
  Forward:  80
Name
  Url: 'http://C'

outputs:

DocId: 20, R:0, D:0
Links.Backward: 10, R:0, D:2
Links.Backward: 30, R:1, D:2
Links.Forward: 80, R:0, D:2
Name.Language.Code: NULL, R:0, D:1
Name.Language.Country: NULL, R:0, D:1
Name.Url: http://C, R:0, D:2

https://github.com/Parquet/parquet-mr/wiki/The-striping-and-assembly-algorithms-from-the-Dremel-paper

 

个人:parquet和rcfile都是先行分割,再进行列分割,而rcfile更擅长于关系型数据,即没有嵌套功能。而parquet,则处理的是有嵌套类型的数据。数据分成多列分别存储,如何将多列的数据正确拿出来,组成一个完成的呢?其实,个人感觉,两种方式,每一个单独存放列的数据都是按照数据存放的,即使某行数据,在那列是空的,也会在对应的列存储数据上放上一个空的。

例如,

351de516e064ff3ea9668794a6e5fbb3

即使没有电话号码,也放一个空的,这样,就可以将多个列存储的数据,正确的合并成一个行了。

本文转载自:http://lastorder.me/tag/parquet.html

水东流
粉丝 4
博文 51
码字总数 23858
作品 0
海淀
程序员
私信 提问
Parquet_2. 在 Impala/Hive 中使用 Parquet 格式存储数据

们已经介绍过在 Hive 中使用 Avro,Parquet 格式来存储数据。今天我们将介绍一下如何在 Impala中使用 Parquet 格式。 1. 跟 Hive 中一样,我们在创建表的时候可以通过 STORED AS PARQUET 语句...

Zero零_度
2016/09/25
219
0
Impala 表使用 Parquet 文件格式

Impala 表使用 Parquet 文件格式 Impala 帮助你创建、管理、和查询 Parquet 表。Parquet 是一种面向列的二进制文件格式,设计目标是为 Impala 最擅长的大规模查询类型提供支持(Parquet is a...

weiqingbin
2014/01/20
14.5K
0
Parquet 支持数据嵌套的列式数据存储格式

简介 Apache Parquet 是一个列存储格式,主要用于 Hadoop 生态系统。对数据处理框架、数据模型和编程语言无关。Cloudera的大数据在线分析(OLAP)项目Impala中使用该格式作为列存储。 Parque...

cloud-coder
2015/06/17
3.1K
0
Spark 中关于Parquet的应用与性能初步测试

Spark 中关于Parquet的应用 Parquet简介 Parquet是面向分析型业务的列式存储格式,由Twitter和Cloudera合作开发,2015年5月从Apache的孵化器里毕业成为Apache顶级项目 http://parquet.apach...

去买大白兔
2017/05/21
0
0
Apache Parquet MR 1.6.0 (Incubating) 发布

Parquet是一种面向列存存储的文件格式,Cloudera的大数据在线分析(OLAP)项目Impala中使用该格式作为列存储。 Apache Parquet 是一个列存储格式,主要用于 Hadoop 生态系统。对数据处理框架...

oschina
2015/04/20
1K
1

没有更多内容

加载失败,请刷新页面

加载更多

Mybatis Plus删除

/** @author beth @data 2019-10-17 00:30 */ @RunWith(SpringRunner.class) @SpringBootTest public class DeleteTest { @Autowired private UserInfoMapper userInfoMapper; /** 根据id删除......

一个yuanbeth
今天
4
0
总结

一、设计模式 简单工厂:一个简单而且比较杂的工厂,可以创建任何对象给你 复杂工厂:先创建一种基础类型的工厂接口,然后各自集成实现这个接口,但是每个工厂都是这个基础类的扩展分类,spr...

BobwithB
今天
5
0
java内存模型

前言 Java作为一种面向对象的,跨平台语言,其对象、内存等一直是比较难的知识点。而且很多概念的名称看起来又那么相似,很多人会傻傻分不清楚。比如本文我们要讨论的JVM内存结构、Java内存模...

ls_cherish
今天
4
0
友元函数强制转换

友元函数强制转换 p522

天王盖地虎626
昨天
5
0
js中实现页面跳转(返回前一页、后一页)

本文转载于:专业的前端网站➸js中实现页面跳转(返回前一页、后一页) 一:JS 重载页面,本地刷新,返回上一页 复制代码代码如下: <a href="javascript:history.go(-1)">返回上一页</a> <a h...

前端老手
昨天
5
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部