将long类型变量num作为参数,实现计算该数字乘积的持续性的方法 persistence()?

优秀的编程模型可以让程序在不同SM数量的GPU中用有可移植性。

内存、显存,UM(Unified Memory)提供统一的地址映射,减少内存、显存之间的拷贝(mirroring)

讲述了nvcc编译异构代码的过程

驱动会在runtime将PTX编译成二进制,这叫做JIT编译。缺点是增加了application load time,优点是可以让应用可以获取驱动更新所带来的编译优化,比如应用编译的时间早于新驱动发布的时间,可以利用新驱动的优化。

旧的PTX总可以在新的compute capability上编译成二进制,但是可能用不上新硬件上的新功能,比如tensor core。

(这就解释了为什么TF 编译XLA的时候默认使用ptxas编到cubin,但是有个判断如果编不出cubin,可能当前硬件太新了,就把ptx丢给驱动去编)

没有显式的初始化函数,在任何一个(除了显示版本信息和报错的函数)runtime函数被调用的时候,进行初始化

runtime为每一个device创建一个context,这个context叫做primary context,是第一次调用runtime函数时初始化的。所有host端线程共享它。创建这个context 的过程中,前面所说的JIT 编译过程,如果需要的话,也会编译device代码,然后拷贝到device内存中。这些的发生是透明的。

线性内存的寻址空间在我们常见的场景下是48bit

由于上述接口可能会申请很大的内存,建议有个try - catch 的备选分配代码

如果一个区域的内存被反复访问,则可以把它看作persisting, 如果只访问一次,则可以看作streaming

也就是说可以从L2 cache中分离一部分出来做数据的持久化,只有这部分空间不被persisting使用的时候,normal\streaming的访问才可以使用它。

这个set-aside空间的大小可以通过接口调整。

当使用MPS的时候,set-aside大小只能由启动MPS的时候指定。

后续的几个小标题介绍了很多L2  set-aside cache的用法,用到的时候再仔细看。

在一些设备上的D2H H2D拷贝可以和kernel并行执行(见3.2.6.3,当计算与拷贝有数据依赖时,想要并行,必须用锁页内存)

在一些情况下锁页的内存拷贝更快,见3.2.5.2

注意,锁页host内存是稀缺资源,申请锁页内存更容易失败,而且它减少了操作系统可支配的内存从而会减少系统整体性能。

从write-combining 内存读取数据非常慢,因此它一般用于host只写的场景。

通过另一个flag可以将锁页内存地址映射到device地址空间。这种内存块就会有两个地址:host端、device端,分别用两种接口可以获得。一个例外是 .的情况,只有一个统一的地址。

这样从kernel中访问host内存并不能像访问device内存一样快。但是有以下优势:

1. 不必在device端分配内存、拷贝内存,数据传输是隐式的

2.不必使用streams来掩盖数据拷贝与kernel计算,这种overlap是自动进行的了

由于映射锁页内存是host和device公用的,我们就必须利用stream或event来同步,避免写后读、读后写等冲突

而且必须在支持这个功能的设备上。

以下操作可以互相并行:

以下这些device端操作对于host来说是异步的

通过将环境变量CUDA_LAUNCH_BLOCKING设为1,可以全局地关闭kernel launch的并行,这个功能是debug用的,不应该用于生产。(补充,这是因为这个变量只有在cuInit的时候才会被读取,进程中之后对他的修改是不会起作用的)

profiler(Nsight 等)的硬件计数器通常会使得kernel launch变成同步的,除非设置一下。如果host端内存不是锁页的,那么相关的Async的拷贝也会变成同步的。

一些设备支持这种并行。(3.2.6.1说的是host层面,这节是对于device来说的,它可同时做这两种事情)。如果涉及host内存,它必须是锁页的。

高版本硬件支持双向数据拷贝之间的overlap,也是需要锁页内存

应用程序通过stream来管理并行操作,stream是顺序执行的操作。

以下操作隔开的两个stream的操作不能同步:

还有一些其他有关check的条件

阐述了overlap行为与软件的写法以及硬件是否支持相关的并行有关。并举例如何优化。

显式API需要将图节点一个个地加入进去。

 




当图出现变化,比如拓扑、数据类型出现变化的时候,就需要重新初始化图,会带来一些开销。但是当只有一些参数(比如地址)变化的时候,就没必要重新初始化,提供了Graph Update接口来实现这种功能。
下面介绍了两种update的方式

cudaGraph_t对象不是线程安全的。用户必须保证不同线程不能同时访问同一个cudaGraph。




靠,上次写的东西没了,辣鸡博客系统!










首先讲述了warp、warp scheduler,warp内部线程处理分支的方法。Volta架构前后的不同。(独立的pc)


5.1总体的4个基础策略:
  • 最大化并行执行以提高使用率
  • 优化内存使用以达到最大内存吞吐
  • 优化指令使用以达到最大指令吞吐
  • 最小化内存thrashing(颠簸,内存抖动,反复换入换出)
 
那种策略的性能收益最高取决于应用本身的性能限制。性能的优化需要针对性能的瓶颈,否则可能一无所获。将kernel的浮点数计算吞吐和内存吞吐(通常内存吞吐更有意义)与硬件本身的理论上限进行比较,可以得知kernel的提升空间。



高层面应用应该最大化host、device以及它们之间的总线(PCIe)之间的并行度。使用异步的函数调用和stream。而且应该让不同的设备做它们擅长的工作:host做串行工作,device做并行工作。
对于并行工作来说,当线程之间需要同步的时候则会打断这种并行。有两种情况:需要同步的线程在同一个thread block中,则使用__sync_threads()就可以。又或者线程在不同的block中,则需要通过global mem来进行数据交换,还需要两个kernel调用来完成,第一个kernel写数据到global mem,第二个从global mem中读数据。第二种情况的开销要大的多,kernel launch的开销和访问内存的开销。因此我们需要尽量避免第二种情况,在将算法用CUDA programming model实现的时候就要考虑到。

在低层,应用应该最大化设备的多个SM之间的并行。或者通过多个stream来让多个kernel来并行地执行,从而最大化utilization


之后阐释了warp、指令级并行、流水等概念(英文不太好理解),解释了数据依赖对指令latency的影响。以及arithmetic intensity(操作数含有offchip数据的指令数,与,操作数不含offchip数据的指令个数,的比例)的影响



一个kernel使用的寄存器个数,对并行的warp(resident warp)个数有很大影响,举了个例子。所以,编译器会在最小化寄存器使用个数的同时,最小化寄存器spilling个数和指令个数。



5.3 最大化内存吞吐
首先要最小化host到device的数据搬运,因为带宽小的多。


share mem是软件可控的cache,而对于一些应用来说,有时用传统的硬件cache使用方式有可能更好(比如数据局部性本身就很强)
然后就是global memory的访存模式要优化,这里5.3.2会介绍。

将尽可能多的代码使用device而不是host实现,即使一些kernel可能并行度并不是最适合使用GPU来执行。中间的数据结构可能整个生命周期都在device端而从未在host端出现。
将多次小的数据搬运合并成少量大的数据搬运
在有front-side bus的系统中,可以使用锁页内存
在使用mapped pagelocked memory的时候,没有必要在device端再分配一块内存然后再把数据拷贝过去,拷贝是隐式进行的。条件是mapped pagelocked mem只访问很少的次数。这些前面讲过。


不同的thread 中的内存访问指令,可能会被不同thread执行多次。不同类型的内存,其内存分布对访存的影响不同。例如对于global memory,内存越分散,吞吐越低。


当一个warp的指令访问内存时,他将warp中的thread的内存访问合并(coalesce)为一个或多个(取决于每个thread的访问大小和地址分布)transaction。总的来说,所需的transaction越大,不必要的多余数据传输越多,从而降低指令吞吐。例如,当transaction为32字节时,每个thread传输4字节,那么吞吐就会降低8倍。

因此要最大化内存吞吐,最大化内存合并就很重要,有以下几点:
  • 遵循最优访存模式,每个compute compability的介绍见附录K,(包含对于read-only数据的加速方法
  • 使用符合对齐要求的数据类型
  • 某些情况下对数据做padding
 

global memory支持数据大小为1,2,4,8,16字节的读写,当且仅当访问的数据类型是1,2,4,8,16字节且数据是自然对齐(地址是这个size的倍数)的时候,对global memory的访问才会被编译成一条指令。
如果这个size和对齐要求没有被满足,那么指令将会编译成多条有重叠的指令,不能被合并(coalesce)。所以推荐使用符合这个条件的数据类型。

对于结构体来说,可以用__align__来指定对齐
任何global memory中的变量地址,或通过驱动分配获得的地址,都是天然满足256字节对齐的
读取任何非自然对齐的8字节或16字节对齐的字,会导致错误的结果(有offset),因此需要特别注意。一个典型的场景就是自定义的地址分配,比如用一个调用来分配多维数组,此时每一小段都会有个offset,就不一定是对齐的了。

对二维数组的访问一般使用这样的公式来计算地址:
 

特别地,如果数组不满足上述需求,将它们pad成满足对齐的需求的数组,就会大大提高访问效率。cudaMallocPitch() and cuMemAllocPitch()就是方便做这种事情的。

  • 不能储存到寄存器中的大结构体
 


shared memory在片上,分bank,每个bank之间可以并行访问。如果n个访问访问不同bank,那么它们可以并行,如果访问同一个bank,那么它们发生了bank冲突,就需要串行。


在设备内存中,并使用const cache来缓存。


5.4 最大化指令吞吐
  • 避免使用吞吐低的指令,这包括在不影响精度的时候使用低精度。使用intrinsic 函数 (附录H)
  • 减少指令条数。包括减少同步点, ,使用__restricted__ (告诉编译器这几个变量是互相独立的,无关联的)
 

表格给出了各个计算指令的吞吐
 
 
 

要使得控制流指令对性能的影响最小,需要使得divergent warp的数量最少。比如,当条件分支取决于threadIdx / warpsize的时候,这时候每个warp内部的行为是一致的,就没有divergent warp。

有时编译器会将短的条件分支使用谓词(predicate)实现,这种情况下就不会出现divergent。每条指令都会执行,但只有predicate为true的指令才会生效。

 


5.5 最小化内存抖动
经常分配、释放内存的应用,可能会随着时间推移而变慢到一个limit。这在典型情况下是因为释放内存给操作系统导致的。(我自己的pytorch服务就有这种现象)可以注意以下几点:
  • 以合适的大小提前分配好空间,尽量减少程序中cudaMalloc cudaFree的出现次数,尤其是性能瓶颈的地方。
  • 如果一个应用程序不能分配足够的device内存,可以使用cudaMallocHost or cudaMallocManaged分配的其他内存类型。这样不是性能最好的,但程序起码能继续进行下去。
 




太多了,可以作为工具书来看。

有关于block间的同步机制








context像智能指针一样有一个计数器,减到0时候自动删除
}

13 如何正确使用 @Entity 里面的回调方法?

本课时我要介绍的是 @Entity 的回调方法。

为什么要讲回调函数呢?因为在工作中,我发现有些同事会把这个回调方法用得非常复杂,不得要领,所以我专门拿出一个课时来为你详细说明,并分享我的经验供你参考。我将通过“语法 + 实践”的方式讲解如何使用 @Entity 的回调方法,从而达到提高开发效率的目的。下面开始本课时的学习。

关于上表所述的几个方法有一些需要注意的地方,如下:

  1. 回调函数都是和 mit 在同一个线程里面执行的,只不过调用方法有先后之分,都是同步调用,所以当任何一个回调方法里面发生异常,都会触发事务进行回滚,而不会触发事务提交。

  2. Callbacks 注解可以放在实体里面,可以放在 super-class 里面,也可以定义在 entity 的 listener 里面,但需要注意的是:放在实体(或者 super-class)里面的方法,签名格式为“void ()”,即没有参数,方法里面操作的是 this 对象自己;放在实体的 EntityListener 里面的方法签名格式为“void (Object)”,也就是方法可以有参数,参数是代表用来接收回调方法的实体。

JPA 里面规定的回调方法还有一些,但不常用,我就不过多介绍了。接下来,我们看一下回调注解在实体里面是如何使用的。

这里我介绍两种方法,是你可能会在实际工作中用到的。

第一步:修改 BaseEntity,在里面新增回调函数和注解,代码如下:

在这一步骤中需要注意的是:

  1. 我们上面注释的代码,也可以改变 entity 里面的值,但是在这个 Listener 的里面我们不做修改,所以把 setVersion 和 setCreateUserId 注释掉了,要注意测试用例里面这两处也需要修改。

  2. 如果在 @PostLoad 里面记录日志,不一定每个实体、每次查询都需要记录日志,只需要对一些敏感的实体或者字段做日志记录即可。

  3. 回调函数时我们可以加上参数,这个参数可以是父类 Object,可以是 BaseEntity,也可以是具体的某一个实体;我推荐用 BaseEntity,因为这样的方法是类型安全的,它可以约定一些框架逻辑,比如 getCreateUserId、getLastModifiedUserId 等。

第二步:还是一样的道理,写一个测试用例跑一下。

通过日志我们可以很清晰地看到 callback 注解标注的方法的执行过程,及其实体参数的值。你就会发现,原来自定义 EntityListener 回调函数的方法也是如此简单。

细心的你这个时候可能也会发现,我们上面其实应用了两个 EntityListener,所以这个时候 @EntityListeners 有个加载顺序的问题,你需要重点注意一下。

  1. EntityListeners 和实体里面的回调函数注解可以同时使用,但需要注意顺序问题;

  2. 如果我们不想加载super-class里面的EntityListeners,那么我们可以通过注解 @ExcludeSuperclassListeners,排除所有父类里面的实体监听者,需要用到的时候,我们再在子类实体里面重新引入即可,代码如下:

看完了上面介绍的两种方式,关于 Callbacks 注解的用法你是不是已经掌握了呢?我强调需要注意的地方你要重点看一下,并切记在应用时不要搞错了。

上面说了这么多回调函数的注解使用方法,那么它的最佳实践是什么呢?

我以个人经验总结了几个最佳实践。

1.回调函数里面应尽量避免直接操作业务代码,最好用一些具有框架性的公用代码,如上一课时我们讲的 Auditing,以及本课时前面提到的实体操作日志等;

2.注意回调函数方法要在同一个事务中进行,异常要可预期,非可预期的异常要进行捕获,以免出现意想不到的线上 Bug;

3.回调函数方法是同步的,如果一些计算量大的和一些耗时的操作,可以通过发消息等机制异步处理,以免阻塞主流程,影响接口的性能。比如上面说的日志,如果我们要将其记录到数据库里面,可以在回调方法里面发个消息,改进之后将变成如下格式:

4.在回调函数里面,尽量不要直接在操作 EntityManager 后再做 session 的整个生命周期的其他持久化操作,以免破坏事务的处理流程;也不要进行其他额外的关联关系更新动作,业务性的代码一定要放在 service 层面,否则太过复杂,时间长了代码很难维护;(ps:我曾经看到有人把回调函数用得十分复杂,做各种状态流转逻辑,时间长了连他自己也不知道是干什么的,耦合度太高了,你一定要谨慎。)

5.回调函数里面比较适合用一些计算型的transient方法,如下面这个操作:

6.JPA 官方比较建议放一些默认值,但是我不是特别赞同,因为觉得那样不够直观,我们直接用字段初始化就可以了,没必要在回调函数里面放置默认值。

那么除了日志,还有没有其他实战应用场景呢?

确实目前除了日志,Auditing 稍微公用一点,其他公用的场景不多。当遇到其他场景,你可以根据不同的实体实际情况制定自己独有的 EntityListener 方法,如下:

例如,User 中我们有个计算年龄的逻辑要独立调用,就可以在持久化之前调用此方法,新建一个自己的 UserListener 即可,代码如下:

以上,关于 JPA Callbacks 在一些实际场景中的最佳实践就介绍这些,希望你在应用的时候多注意找方法,避免不必要的操作,也希望我的经验可以帮助到你。

那么 callbacks 的实现原理是什么呢?其实很简单,Java Persistence API规定:JPA 的实现方需要实现功能,需要支持回调事件注解;而 Hibernate 内部负责实现,Hibernate 内部维护了一套实体的 EventType,其内部包含了各种回调事件,下面列举一下:

通过一步一步断点,再结合 Hibernate 的官方文档,可以了解内部 EventType 事件的创建机制,由于我们不常用这部分原理,知道有这么回事即可,你有兴趣也可以深入 debug 研究一下。

到这里,本课时内容就介绍这么多。这一节,我们分析了语法,列举了实战使用场景及最佳实践,相信通过上面提到的异常、异步、避免死循环等处理方法,你已经知道回调函数的正确使用方法了。其中最佳实践场景也欢迎你补充,我们可以一起探讨。

下一课时,我们将迎来很多人都感兴趣的“乐观锁机制和重试机制”相关内容,到时候我会告诉你它们在实战中都是怎么使用的。

点击下方链接查看源码:(不定时更新)


14 乐观锁机制和重试机制在实战中应该怎么用

你好,欢迎来到第 14 课时,本课时我要为你揭晓乐观锁机制的“神秘面纱”,在前面的留言中,我看到很多人对这部分内容很感兴趣,因此希望通过我的讲解,你可以打开思路,真正掌握乐观锁机制和重试机制在实战中的用法。那么乐观锁到底是什么呢?它的神奇之处到底在哪?

乐观锁在实际开发过程中很常用,它没有加锁、没有阻塞,在多线程环境以及高并发的情况下 CPU 的利用率是最高的,吞吐量也是最大的。

而 Java Persistence API 协议也对乐观锁的操作做了规定:通过指定 @Version 字段对数据增加版本号控制,进而在更新的时候判断版本号是否有变化。如果没有变化就直接更新;如果有变化,就会更新失败并抛出“OptimisticLockException”异常。我们用 SQL 表示一下乐观锁的做法,代码如下:

假设本次查询的 version=1,在更新操作时,加上这次查出来的 Version,这样和我们上一个版本相同,就会更新成功,并且不会出现互相覆盖的问题,保证了数据的原子性。

这就是乐观锁在数据库里面的应用。那么在 Spring Data JPA 里面怎么做呢?我们通过用法来了解一下。

JPA 协议规定,想要实现乐观锁可以通过 @Version 注解标注在某个字段上面,并且可以持久化到 DB 即可。其支持的类型有如下四种:

这样就可以完成乐观锁的操作。我比较推荐使用 Integer 类型的字段,因为这样语义比较清晰、简单。

我们通过如下几个步骤详细讲一下 @Version 的用法。

第一步:实体里面添加带 @Version 注解的持久化字段。

我在上一课时讲到了 BaseEntity,现在直接在这个基类里面添加 @Version 即可,当然也可以把这个字段放在 sub-class-entity 里面。我比较推荐你放在基类里面,因为这段逻辑是公共的字段。改动完之后我们看看会发生什么变化,如下所示:

其中,我们通过 @Transactional 开启事务,并且在查询方法后面模拟复杂业务逻辑,用来呈现多线程的并发问题。

第五步:按照惯例写个测试用例测试一下。

从上面的测试得到的结果中,我们执行 testVersion(),会发现在 save 的时候, Version 会自动 +1,第一次初始化为 0;update 的时候也会附带 Version 条件。我们通过下图的 SQL,也可以看到 Version 的变化。

而当面我们调用 testVersionException() 测试方法的时候,利用多线程模拟两个并发情况,会发现两个线程同时取到了历史数据,并在稍后都对历史数据进行了更新。

由此你会发现,第二次测试的结果是乐观锁异常,更新不成功。请看一下测试的日志。

通过日志又会发现,两个 SQL 同时更新的时候,Version 是一样的,是它导致了乐观锁异常。

注意:乐观锁异常不仅仅是同一个方法多线程才会出现的问题,我们只是为了方便测试而采用同一个方法;不同的方法、不同的项目,都有可能导致乐观锁异常。乐观锁的本质是 SQL 层面发生的,和使用的框架、技术没有关系。

那么我们分析一下,@Version 对 save 的影响是什么,怎么判断对象是新增还是 update?

通过上面的实例,你不难发现,@Version 底层实现逻辑和 @EntityListeners 一点关系没有,底层是通过 Hibernate 判断实体里面是否有 @Version 的持久化字段,利用乐观锁机制来创建和使用 Version 的值。

其中,我们先看第一段逻辑,判断其中是否有 @Version 标注的属性,并且该属性是否为基础类型。如果不满足条件,调用 super.isNew(entity) 方法,而 super.isNew 里面只判断了 ID 字段是否有值。

第二段逻辑表达的是,如果有 @Version 字段,那么看看这个字段是否有值,如果没有就返回 true,如果有值则返回 false。

由此可以得出结论:如果我们有 @Version 注解的字段,就以 @Version 字段来判断新增 / update;如果没有,那么就以 @ID 字段是否有值来判断新增 / update。

需要注意的是:虽然我们看到的是 merge 方法,但是不一定会执行 update 操作,里面还有很多逻辑,有兴趣的话你可以再 debug 进去看看。

我直接说一下结论,merge 方法会判断对象是否为游离状态,以及有无 ID 值。它会先触发一条 select 语句,并根据 ID 查一下这条记录是否存在,如果不存在,虽然 ID 和 Version 字段都有值,但也只是执行 insert 语句;如果本条 ID 记录存在,才会执行 update 的 sql。至于这个具体的 insert 和 update 的 sql、传递的参数是什么,你可以通过控制台研究一下。

总之,如果我们使用纯粹的 saveOrUpdate方法,那么完全不需要自己写这一段逻辑,只要保证 ID 和 Version 存在该有的值就可以了,JPA 会帮我们实现剩下的逻辑。

实际工作中,特别是分布式更新的时候,很容易碰到乐观锁,这时候还要结合重试机制才能完美解决我们的问题,接下来看看具体该怎么做。

乐观锁机制和重试机制在实战中应该怎么用?

我们先了解一下 Spring 支持的重试机制是什么样的。

第四步:新建一个测试用例测试一下。

这里要说的是,我们在测试用例里面执行 @Import(RetryConfiguration.class),这样就开启了重试机制,然后继续在里面模拟了两次线程调用,发现第二次发生了乐观锁异常之后依然成功了。为什么呢?我们通过日志可以看到,它是失败了一次之后又进行了重试,所以第二次成功了。

通过案例你会发现 Retry 的逻辑其实很简单,只需要利用 @Retryable 注解即可,那么我们看一下这个注解的详细用法。

其源码里面提供了很多方法,看下面这个图片。

下面对常用的 @Retryable 注解中的参数做一下说明:

  • maxAttempts:最大重试次数,默认为 3,如果要设置的重试次数为 3,可以不写;

  • value:抛出指定异常才会重试;

  • exclude:指定不处理的异常;

  • value=delay:隔多少毫秒后重试,默认为 1000L,单位是毫秒;

  • multiplier(指定延迟倍数)默认为 0,表示固定暂停 1 秒后进行重试,如果把 multiplier 设置为 1.5,则第一次重试为 2 秒,第二次为 3 秒,第三次为 4.5 秒。

下面是一个关于 @Retryable 扩展的使用例子,具体看一下代码:

可以看到,这里明确指定 SQLException.class 异常的时候需要重试两次,每次中间间隔 100 毫秒。

此外,你也可以利用 SpEL 表达式读取配置文件里面的值。

关于 Retryable 的语法就介绍到这里,常用的基本就这些,如果你遇到更复杂的场景,可以到 GitHub 中看一下官方的 Retryable 文档:。下面再给你分享一个我在使用乐观锁+重试机制中的最佳实践。

乐观锁+重试机制的最佳实践

我比较建议你使用如下配置:

这里明确指定 ObjectOptimisticLockingFailureException.class 等乐观锁异常要进行重试,如果引起其他异常的话,重试会失败,没有意义;而 backoff 采用随机 +1.5 倍的系数,这样基本很少会出现连续 3 次乐观锁异常的情况,并且也很难发生重试风暴而引起系统重试崩溃的问题。

到这里讲的一直都是乐观锁相关内容,那么 JPA 也支持悲观锁吗?

除了乐观锁,悲观锁的类型怎么实现?

你可以看到,UserInfoRepository 里面覆盖了父类的 findById 方法,并指定锁的类型为悲观锁。如果我们将 service 改调用为悲观锁的方法,会发生什么变化呢?如下图所示:

然后再执行上面测试中 testRetryable 的方法,跑完测试用例的结果依然是通过的,我们看下日志。

你会看到,刚才的串行操作完全变成了并行操作。所以少了一次 Retry 的过程,结果还是一样的。但是,你在生产环境中要慎用悲观锁,因为它是阻塞的,一旦发生服务异常,可能会造成死锁的现象。

本课时的内容到这里就介绍完了。在这一课时中,我为你详细讲解了乐观锁的概念及使用方法、@Version 对 Save 方法的影响,分享了乐观锁与重试机制的最佳实践,此外也提到了悲观锁的使用方法(不推荐使用)。

那么现在,你又掌握了 JPA 的一项技能,希望你可以多动手实践,不断总结经验,以提高自己的技术水平。

下一课时,我们看看 JPA 对 Web MVC 开发者都做了哪些支持呢?

点击下方链接查看源码(不定时更新)


}

我要回帖

更多关于 java中的long型变量 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信