武汉冠状病毒还在肆虐, 不过也有了一个超长的假期, 今天已经是1月31日了, 大部分人的新年计划应该还没有完成十二分之一吧, 在自学编程的路上真是一刻也不能放松啊.

这里就是就是FetchType.LAZY和EAGER的区别, 看看一看这两个属性的背后机制. 现在感觉一个好的Java框架就像Spring一样, 一个一个注解的看过来,最后也就明白了.

  1. LAZY和EAGER加载
  2. 代理的秘密
  3. LAZY 集合
  4. 不使用代理
  5. 关系的LAZY加载
  6. 其他的一些问题

LAZY和EAGER加载

Hibernate从数据库中取出数据并且装载到内存中, 有如下方式:

  1. 最通常的方式就是根据一个唯一标识符来取出数据, 比如常用的em.find方法
  2. 通过调用已经取出来的Entity的某些方法来获取与该Entity有关联的内容, 比如getSeller()等, 关系映射和值类型的集合也是采取此种方式加载. 只要上下文还开启, 就可以加载尚未加载的数据.
  3. 使用Java持久化查询语言(JPQL)来进行查询.
  4. 使用CriteriaQuery接口来进行查询
  5. 原生SQL语言

在一个JPA程序中, 一般都会使用上述技术的组合来达成最终目的. 不过这里先不看查询, 而是关心Hibernate的加载策略, 也就是FetchType.LAZYEAGER背后的真相.

这两个东西在类的关联中经常用到, 其背后到底有什么秘密, 就来看看吧. 感觉Java框架到最后, 也就是一个一个注解详细研究过来, 毕竟Spring MVC到现在还没有开始看呢.

FetchType.LAZYEAGER是加载策略, 在映射的时候指定, 仅仅用于关系和集合. 推荐是使用LAZY方式. 将关系映射和集合的加载策略设定为LAZY时, Hibernate仅仅在必要的时候才加载.

Hibernate如何实现LAZY呢, 其实能猜到这些框架的技术, 就是运行时代理, 想到了Spring 的AOP. 也是使用代理. 设计模式中的代理模式也学过, 用一个代理可以实现延迟加载. 看来Hibernate就是综合了这些技术.

代理的秘密

之前学过一个套路, 就是不想直接加载对象的时候, 改用getReference方法. 现在用反射来看一看这个对象究竟是什么:

public static void main(String[] args) {
    EntityManagerFactory emf =
            CaveatEmptorUtil.getEntityManagerFactory();

    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
    transaction.begin();

    MessageVersion message = em.getReference(MessageVersion.class, 1L);
    PersistenceUnitUtil persistenceUtil = emf.getPersistenceUnitUtil();

    assertFalse(persistenceUtil.isLoaded(message));

    Class clz = message.getClass();
    System.out.println("父类是: "+clz.getGenericSuperclass());
    System.out.println("类名是: " + clz.getName());
    System.out.println("以下是接口: ");
    for (Class c : clz.getInterfaces()) {
        System.out.println(c);
    }


    System.out.println(message);

    transaction.commit();
}

打印出的结果是:

父类是: class cc.conyli.model.chapter11.MessageVersion
类名是: cc.conyli.model.chapter11.MessageVersion$HibernateProxy$jUkY3ci5
以下是接口:
interface org.hibernate.proxy.HibernateProxy
interface org.hibernate.proxy.ProxyConfiguration

这里的MessageVersion对象实际上就被延迟加载, 仅仅只有一行getReference()语句的话, 运行程序可以发现, Hibernate没有执行任何SQL语句. 顺便一提, 如果用find方法, 结果是:

父类是: class java.lang.Object
类名是: cc.conyli.model.chapter11.MessageVersion
没有接口

很明显可以看出来, 使用了getReference之后, 得到的是一个代理对象. 只要在代理对象上调用除了获得唯一标识符之外的方法,就会触发代理对象加载数据.(不过在我实验的时候, 使用了getId()也会触发加载).如果使用find, 则根本没有代理对象, Hibernate直接取出结果然后给对象设置上数据.

JPA提供的PersistenceUtil工具, 可以检查一个代理的状态:

assertFalse(persistenceUtil.isLoaded(message,"text"));
assertFalse(persistenceUtil.isLoaded(message,"currentDate"));
assertFalse(Hibernate.isInitialized(message));
assertFalse(persistenceUtil.isLoaded(message,"id"));

实际上, 不仅仅是自定义的映射类被代理, 被关系映射的Set等集合类型, 运行时候的实际类型也是Hibernate生成的代理, 只不过行为和原来的集合完全相同, 所以是透明的. 只要是涉及到需要延迟加载的地方, 背后实际上都是代理.

LAZY 集合

先来看看LAZY集合如何使用. 这里的集合, 依然值的是值类型的集合, 即@ElementCollection注解的集合.

对于所有的集合, Hibernate默认就使用LAZY策略, 无需具体指定策略, 而且比较有趣的是, 执行get方法获取集合的引用, 依然是一个代理对象, 不访问具体对象, 不会加载数据.

public void testLazy() {
    EntityManagerFactory emf =
            Persistence.createEntityManagerFactory("HelloWorldPU");
    EntityManager em = emf.createEntityManager();
    EntityTransaction tx = em.getTransaction();
    tx.begin();

    LinkedHashSetItem item = em.find(LinkedHashSetItem.class, 1L);

    Set<String> images = item.getImages();
    System.out.println("images是否在执行了get方法后被加载: " + Persistence.getPersistenceUtil().isLoaded(item, "images"));
    System.out.println(images.getClass());

    System.out.println(images.iterator().next());
    System.out.println(images.size());
    System.out.println("images是否在读取具体数据后被加载: " + Persistence.getPersistenceUtil().isLoaded(item, "images"));

    tx.commit();

}

item.getImages()之后, 依然没有加载数据, 直到开始遍历Set才开始加载数据, size()方法也会触发加载.

Hibernate有一个特有的功能, 就是一个@org.hibernate.annotations.LazyCollection注解, 可以让集合支持一些无需加载数据的操作, 来修改一下:

@ElementCollection
@CollectionTable(name = "LINK_IMAGE")
@Column(name = "IMAGE_NAME")
@org.hibernate.annotations.OrderBy(clause = "IMAGE_NAME")
@org.hibernate.annotations.LazyCollection(LazyCollectionOption.EXTRA)
protected Set<String> images = new LinkedHashSet<>();

这个时候, 再执行测试:

Set<String> images = item.getImages();
System.out.println("images是否在执行了get方法后被加载: " + Persistence.getPersistenceUtil().isLoaded(item, "images"));
System.out.println(images.size());
System.out.println(images.isEmpty());
System.out.println(images.contains("saner"));
System.out.println("images是否在这些操作后被加载: " + Persistence.getPersistenceUtil().isLoaded(item, "images"));

这个操作支持上述的三个方法, 都不会引起加载数据, 对于Map集合来说, 还支持containsKey()containsValue()两个方法.

不使用代理

刚才可以知道, 正常情况下

默认使用代理, 也有不使用代理的方法. 在持久化类上上加上一个特殊的注解, 就可以让这个类不使用代理,即关闭了拦截器, 这个注解的优先级别要比加在域上的注解优先级要高, 只要关闭了代理, 那些延迟加载的功能就全部失效了.

@Entity
@org.hibernate.annotations.Proxy(lazy = false)
public class User {
    ......
}

加上了这个注解之后, 使用User user = em.getReference(User.class, USER_ID);,就会触发一个SELECT.

User类对于关系映射也一样不会使用代理, 因此会导致类似@ManyToOne(fetch = FetchType.LAZY)这样的语句失效, 实际执行依然是急加载.

这里还是用之前的MessageVersion加上自己写的一个类来测试:

@Entity
@org.hibernate.annotations.Proxy(lazy = false)
public class MessageVersion {
    ......
}

一个Sender类, 其中有一个到MessageVersion的多对一关系:

@Entity
public class Sender {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    private MessageVersion messageVersion;
}

注意现在已经关闭了MessageVersion的代理, 看下列测试代码:

Sender sender = em.find(Sender.class, 25L);

System.out.println("messageVersion是否在find之后被加载: " + Persistence.getPersistenceUtil().isLoaded(sender, "messageVersion"));

这个结果打印的是true, 因为关闭了MessageVersion类的代理, 即使在多对一注解上标记了LAZY也没有用. 如果把MessageVersion类上边的关闭代理代码去掉, 则这个测试打印的是false.

现在想在关闭MessageVersion类的代理的情况下,依然要使用LAZY加载, 就要如此写:

@ManyToOne(fetch = FetchType.LAZY)

@org.hibernate.annotations.LazyToOne(
org.hibernate.annotations.LazyToOneOption.NO_PROXY
)
private MessageVersion messageVersion;

LazyToOneOption.NO_PROXY告诉Hibernate需要在这里使用字节码增强来拦截调用. 此时再运行, 就会发现依然是LAZY加载了. 不过这里运行结果依然是true, 应该是字节码增强器没有起作用. 原书中说未必适用Hibernate5的字节码增强器.

关系的LAZY加载

把集合中的值类型换成Entity就是关系, 所以可以猜到, Hibernate对于关系应该也是默认使用LAZY加载. 实际上并不是, 关系的加载默认策略是EAGER.

这里要注意一个问题, 就是LAZY加载的关系和集合, 如果在加载之前就将其分离(detach), 那么之后再调用getXXX方法, 就会报错. 因此分离的时机要掌握好.

其他的一些问题

这里列出以下其他的一些问题, 都是Hibernate特有的问题, 所以需要Hibernate特有的注解来解决:

  1. n+1查询问题, 这指的是如果使用了LAZY加载, 查出了结果集之后, 对于结果集中的每个LAZY加载的部分进行处理, 则会导致每次处理都再执行一条SQL语句, 导致有n个结果, 最后就总共执行n+1条查询, 而实际上可以用1条JOIN语句一次性全部查出来. 使用EAGER策略会避免这个问题, 然而又会遇到另外一个问题: 笛卡尔积
  2. 笛卡尔积问题, 如果一个映射的持久化类中有多个集合或者关系, 如果全部是EAGER查询, Hibernate会采用JOIN的方式查询, 然后再去掉重复的内容, 这会导致笛卡尔积从而浪费大量内存

上边两个问题都是由于整体设定了加载策略所致, 解决办法就是更详细的进行配置:

  1. 批量创建代理类: 在类上设置每次加载这个类的代理时, 加载的数量, 相当于一次性读取的数量, 注解为: @org.hibernate.annotations.BatchSize(size=n), 当每次加载这个映射类对应的代理类的时候, 会一次性读入n个. 这用来解决n+1问题
  2. 批量加载集合: 上边这个注解也可以设置在集合上, 每次只要一读取这个集合, 就会立刻加载等于n的数量, 当读取超过n的时候, 下一次查询又会立刻加载n的数量, 这样就将n+1问题变成了 (n+1)/n
  3. 使用子查询预抓取集合: 在集合(关系)上添加一个注解@org.hibernate.annotations.Fetch(FetchMode.SUBSELECT), 在加载的时候, 就会直接查出来集合. 这背后使用一个子查询, 所以语句会比较少. 这个注解仅仅可以作用于延迟加载的集合, 而不能用于Entity的代理, 所以对于单个的关系是没有效果的.
  4. 使用SELECT避免笛卡尔积: 与上边的注解类似, 只不过FetchMode改成SELECT, 就会使用额外的多个SELECT去加载, 以避免笛卡尔积. 可见FetchMode就是用来控制具体加载类型, FetchMode的默认是什么呢, 其实就是JOIN.
  5. 动态急加载, 这是最方便的方法, 即在集合上依然设置LAZY, 但是在JPQL查询语句中使用特殊的join fetch来指定使用急加载.

看一个例子, 依然是上边的Sender 和 MessageVersion 类, 但是在MessageVersion中添加一个反向映射 @OneToMany:

@Entity
public class MessageVersion {
    @OneToMany(mappedBy = "messageVersion")
    private Set<Sender> senders = new HashSet<>();
}

编写如下的查询:

List<MessageVersion> messageVersions = em.createQuery("SELECT m FROM MessageVersion m", MessageVersion.class).getResultList();

for (MessageVersion m : messageVersions) {
    System.out.println(m.getSenders());
}

这个查询先查出所有的MessageVersion, 然后对每个MessageVersion查其对应的Senders. 注意看控制台的顺序, 在每次循环的时候, 都是先去查询, 然后打印结果.

执行这个查询之后, 目前MessageVersion一共有5条数据, 然后发现执行了6个SQL语句, 第一个查出所有的MessageVersion, 然后5个, 就是每次循环的时候, 再去查对应的Senders.

现在加上注解:

@OneToMany(mappedBy = "messageVersion")
@org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
private Set<Sender> senders = new HashSet<>();

此时Hibernate在先查出来MessageVersion之后, 就会使用一条SQL语句:

Hibernate:
    /* load one-to-many cc.conyli.model.chapter11.MessageVersion.senders */ select
        senders0_.messageVersion_id as messageV3_1_1_,
        senders0_.id as id1_1_1_,
        senders0_.id as id1_1_0_,
        senders0_.messageVersion_id as messageV3_1_0_,
        senders0_.name as name2_1_0_
    from
        Sender senders0_
    where
        senders0_.messageVersion_id in (
            select
                messagever0_.id
            from
                MessageVersion messagever0_
        )

可以看到, 用in子句来急加载了MessageVersion结果集中全部对象的senders. 这样一共就两条SQL语句.

修改成如下:

@OneToMany(mappedBy = "messageVersion", fetch = FetchType.EAGER)
@org.hibernate.annotations.Fetch(FetchMode.SELECT)
private Set<Sender> senders = new HashSet<>();

这样运行的结果, 总SQL条数不变, 但是控制台顺序会有变化. 最开始什么都不加的时候, 遇到遍历的时候才会去查询. 现在搭配使用之后, 会直接先查出全部数据, 在循环打印的时候, 并不会执行SQL语句.

最后看一下动态急加载, 仅仅指定LAZY加载, 此时已经知道, 循环一次会查一次再打印, 因为是LAZY加载:

@OneToMany(mappedBy = "messageVersion", fetch = FetchType.LAZY)
private Set<Sender> senders = new HashSet<>();

现在在JPQL中指定急加载:

List<MessageVersion> messageVersions = em.createQuery("SELECT m FROM MessageVersion m join fetch m.senders", MessageVersion.class).getResultList();

可以发现Hibernate用一条 JOIN 语句直接加载:

Hibernate:
    /* SELECT
        m
    FROM
        MessageVersion m
    join
        fetch m.senders */ select
            messagever0_.id as id1_0_0_,
            senders1_.id as id1_1_1_,
            messagever0_.currentDate as currentD2_0_0_,
            messagever0_."text" as text3_0_0_,
            messagever0_."version" as version4_0_0_,
            senders1_.messageVersion_id as messageV3_1_1_,
            senders1_.name as name2_1_1_,
            senders1_.messageVersion_id as messageV3_1_0__,
            senders1_.id as id1_1_0__
        from
            MessageVersion messagever0_
        inner join
            Sender senders1_
                on messagever0_.id=senders1_.messageVersion_id

从上边可以看出, 这四种方法, 本质都是为了解决n+1或者笛卡尔积这两个问题, 而解决的方法只能同时聚焦于解决某一个问题, 所以要根据情况灵活使用.