Skip to content

Fix memory leak in SpotifyApi via Unmanaged ThreadLocal (Resolves #450)#451

Open
QiuYucheng2003 wants to merge 2 commits intospotify-web-api-java:mainfrom
QiuYucheng2003:fix-memory-leak-issue-450
Open

Fix memory leak in SpotifyApi via Unmanaged ThreadLocal (Resolves #450)#451
QiuYucheng2003 wants to merge 2 commits intospotify-web-api-java:mainfrom
QiuYucheng2003:fix-memory-leak-issue-450

Conversation

@QiuYucheng2003
Copy link

Fixes #450

Description

In high-concurrency environments or when using long-lived thread pools (e.g., Spring Boot default executors, Tomcat), the ThreadLocal<SimpleDateFormat> used for SIMPLE_DATE_FORMAT acts as an Unmanaged ThreadLocal (UTL). Because ThreadLocal.remove() is never invoked, it retains instances indefinitely in the thread's ThreadLocalMap, leading to memory leaks over time.

Changes Made

  • Replaced ThreadLocal<SimpleDateFormat> with Java 8's java.time.format.DateTimeFormatter. Since DateTimeFormatter is inherently thread-safe and immutable, it completely eliminates the need for thread-local storage while keeping the exact same formatting rules.
  • Ensured Backward Compatibility: The legacy SimpleDateFormat was lenient and would quietly ignore trailing characters (like Z or milliseconds .750Z) after reaching the 19-character pattern limit. DateTimeFormatter is strictly validated. To preserve absolute backward compatibility and avoid breaking existing integrations, parseDefaultDate now safely truncates the input string to 19 characters before parsing.
  • Wrapped DateTimeParseException into the legacy ParseException so existing catch blocks in user code will not break.

All 338 unit tests pass successfully. Pull request ready for review!

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a memory leak caused by an unmanaged ThreadLocal in the SpotifyApi class. In high-concurrency environments with long-lived thread pools, the ThreadLocal instances were never cleaned up, leading to memory leaks over time.

Changes:

  • Replaced ThreadLocal<SimpleDateFormat> with java.time.format.DateTimeFormatter, which is thread-safe and immutable
  • Updated parseDefaultDate to use the new formatter with string truncation for backward compatibility
  • Updated formatDefaultDate to use the new formatter

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +175 to +179
String parsedDate = (date != null && date.length() > 19) ? date.substring(0, 19) : date;

return Date.from(LocalDateTime.parse(parsedDate, SIMPLE_DATE_FORMATTER)
.atZone(ZoneId.of("GMT"))
.toInstant());
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truncation logic doesn't handle the case where date is null before calling substring. If a null date is passed, it will pass through to LocalDateTime.parse(null, ...) which will throw a NullPointerException instead of the more meaningful ParseException that the old implementation would have thrown. Consider adding explicit null handling or ensuring the null check happens before parsing.

Suggested change
String parsedDate = (date != null && date.length() > 19) ? date.substring(0, 19) : date;
return Date.from(LocalDateTime.parse(parsedDate, SIMPLE_DATE_FORMATTER)
.atZone(ZoneId.of("GMT"))
.toInstant());
if (date == null) {
throw new ParseException("Date string is null", 0);
}
String parsedDate = date.length() > 19 ? date.substring(0, 19) : date;
try {
return Date.from(LocalDateTime.parse(parsedDate, SIMPLE_DATE_FORMATTER)
.atZone(ZoneId.of("GMT"))
.toInstant());
} catch (DateTimeParseException e) {
ParseException pe = new ParseException("Failed to parse date: " + parsedDate, 0);
pe.initCause(e);
throw pe;
}

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +174
// return SIMPLE_DATE_FORMAT.get().parse(date);
//
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented-out code should be removed rather than left in the codebase. Version control systems maintain the history, so there's no need to keep old code as comments.

Copilot uses AI. Check for mistakes.
*/
public static String formatDefaultDate(Date date) {
return SIMPLE_DATE_FORMAT.get().format(date);
// return SIMPLE_DATE_FORMAT.get().format(date);
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented-out code should be removed rather than left in the codebase. Version control systems maintain the history, so there's no need to keep old code as comments.

Copilot uses AI. Check for mistakes.
String parsedDate = (date != null && date.length() > 19) ? date.substring(0, 19) : date;

return Date.from(LocalDateTime.parse(parsedDate, SIMPLE_DATE_FORMATTER)
.atZone(ZoneId.of("GMT"))
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ZoneId.of("GMT") is created on every call to parseDefaultDate. Since the SIMPLE_DATE_FORMATTER already has the zone set via withZone(ZoneId.of("GMT")), you can use the formatter's zone directly or cache the ZoneId as a constant to avoid repeated object creation. However, note that ZoneId instances are cached internally by the JDK, so the performance impact is minimal.

Suggested change
.atZone(ZoneId.of("GMT"))
.atZone(SIMPLE_DATE_FORMATTER.getZone())

Copilot uses AI. Check for mistakes.
* {@code yyyy-MM-dd'T'HH:mm:ss}
*/
private static final ThreadLocal<SimpleDateFormat> SIMPLE_DATE_FORMAT = ThreadLocal.withInitial(() -> makeSimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", "GMT"));
// private static final ThreadLocal<SimpleDateFormat> SIMPLE_DATE_FORMAT = ThreadLocal.withInitial(() -> makeSimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", "GMT"));
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented-out code should be removed rather than left in the codebase. Version control systems maintain the history, so there's no need to keep old code as comments.

Copilot uses AI. Check for mistakes.
@dargmuesli
Copy link
Member

@QiuYucheng2003 thanks for the pull request! If possible, please comment on the review threads above and let me know for each how you'd like to resolve them.

- Add explicit null check in parseDefaultDate
- Wrap DateTimeParseException into ParseException for backward compatibility
- Reuse ZoneId from the formatter to optimize performance
- Remove commented-out legacy code
@QiuYucheng2003
Copy link
Author

@dargmuesli
Thanks for the review! I have addressed all the feedback and suggestions:

Added explicit null check in parseDefaultDate.

Wrapped DateTimeParseException into ParseException to maintain backward compatibility for existing catch blocks.

Optimized ZoneId retrieval by reusing the formatter's zone.

Cleaned up all the commented-out legacy code.

Please take another look when you have time. Thank you!

Comment on lines +100 to +101
private static final DateTimeFormatter SIMPLE_DATE_FORMATTER = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private static final DateTimeFormatter SIMPLE_DATE_FORMATTER = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
private static final DateTimeFormatter SIMPLE_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE

See Javadoc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Memory Leak (Unmanaged ThreadLocal) in SpotifyApi via SIMPLE_DATE_FORMAT

4 participants