JPA - Lazy Performance Differences, OneToOne Bidirectional Lazy
This post was migrated from Tistory. You can find the original here.
LAZY vs EAGER
Suppose entity A has the following foreign keys. If you don’t specify a fetch type, it defaults to EAGER.
With the EAGER option, querying A using the basic methods provided by JpaRepository (findById, findAll) produces 4 JOIN queries.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
@JoinColumn(name = "locate_id", referencedColumnName = "id")
private Locate locate;
@OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
@JoinColumn(name = "riskAnalysis_id", referencedColumnName = "id")
private RiskAnalysis riskAnalysis;
@ManyToOne
@JoinColumn(name = "device_id", referencedColumnName = "id")
private Device device;
@OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
@JoinColumn(name = "condition_id", referencedColumnName = "id")
private Condition condition;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hibernate:
select
~~~
from
data d
left join
device d1_0
on d1_0.id=d.device_id
left join
sensor s1_0
on s1_0.id=d1_0.sensor_id
left join
locate l1_0
on l1_0.id=d.locate_id
left join
risk_analysis ra1_0
on ra1_0.id=d.risk_analysis_id
left join
condition c1_0
on c1_0.id=d.condition_id
where
d.id=?
And for example, if Device also had a foreign key to Sensor, the JOIN query for that relationship would be generated too.
(It recursively triggers JOIN queries for everything.)
Now let’s apply LAZY as shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
@JoinColumn(name = "locate_id", referencedColumnName = "id")
private Locate locate;
@OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
@JoinColumn(name = "riskAnalysis_id", referencedColumnName = "id")
private RiskAnalysis riskAnalysis;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "device_id", referencedColumnName = "id")
private Device device;
@OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
@JoinColumn(name = "condition_id", referencedColumnName = "id")
private Condition condition;
1
2
3
4
5
6
7
Hibernate:
select
~~
from
data d
where
d.id=?
There’s no JOIN, and an additional SELECT query is issued only when the FK object is actually accessed.
Performance differences based on JOIN count
The FKs are keyed on id, so an index already exists by default. (Spring Data JPA creates indexes on PKs and UKs by default.)
1
@Query("SELECT d FROM Data d WHERE d.status = :status AND d.success = :success")
Below are the timings when running a findAll with a JPQL query that has 2 WHERE conditions applied.
The exact numbers will vary depending on server spec and environment. Just look at this as an illustration of the relative gap.
| Row count | LAZY (0 JOINs) (ms) | EAGER (5 JOINs) (ms) |
| 1000 | 600~800 | 2100~2400 |
| 2000 | 1100~1300 | 4200~4500 |
| 3000 | 1700~1900 | 6200~6500 |
| 4000 | 2300~2500 | 8400~8700 |
The gap widens as the amount of data grows. The more data you’re handling, the bigger the impact of joins.
To cut down on unnecessary JOINs, it seems like a good idea to set FKs to LAZY by default.
Also, using LAZY can cause issues when returning Entity objects directly in a response (LazyInitializationException, etc.), so it’s best to always convert Entities to DTOs. Even if you haven’t finalized your design yet, converting Entities to DTOs up front makes maintenance much easier down the line.
What about LAZY on the non-owning side of a bidirectional relationship?
1
2
3
4
5
@OneToOne(mappedBy = "Data", optional = false, cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
private ExtraData extraData;
@OneToMany(mappedBy = "Data", cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
private List<Test> testList;
@OneToMany defaults to fetch = FetchType.LAZY. (A SELECT is issued when that object is accessed.)
Note: On the non-owning side, @OneToMany is also related to the N+1 problem.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Hibernate:
select
~~~
from
data d1_0
where
d1_0.id=?
Hibernate:
select
~~~
from
extra_data ed1_0
where
ed1_0.data_id=?
Regardless of FetchType.LAZY/EAGER, @OneToOne issues a SELECT rather than a JOIN when queried. This holds true even when optional = false is set.
Why does this happen?
The non-owning side has no column for the FK, so it doesn’t know whether that FK is NULL or not — hence the SELECT.
So why can @OneToMany behave lazily?
| Characteristic | @OneToOne (entity proxy) | @OneToMany (collection proxy) |
| Initialization method | Initialized as a single entity proxy | Initialized as a collection proxy object |
| Data load timing | When the entity field is accessed | When a collection method is accessed |
The collection proxy is a type provided by the JPA implementation (Hibernate).
You’d need to debug to see the exact data structure, but a collection proxy can represent NULL, while an entity proxy cannot.
Can represent NULL = it’s fine for NULL to come in = SELECT isn’t mandatory
Cannot represent NULL = NULL is not allowed = SELECT is mandatory