Overview

ByteBuffers are not meant to be accessed concurrently. But our NioFilter classes, particularly, NioSslEngine manipulate ByteBuffers, and are themselves subject to concurrent access. As a result it's often necessary to protect ByteBuffers from concurrent access.

Not only must we protect ByteBuffers from concurrent access, we also need to ensure that ByteBuffers are returned to their pool when they are no longer needed.

The purpose of the ByteBuffer sharing framework is to provide this coordination and pool management.

The framework leverages the try-with-resources syntax to deliver exclusive ByteBuffer access in lexical blocks. The ByteBufferSharing class provides a couple open() methods (one of which supports timeout). These methods return an AutoCloseable "resource", suitable for assignment in a try-with-resources variable declaration. The Java language guarantees that close() will be called on such a resource when the lexical block is left (either through normal program flow or exception). The framework leverages that close() call as a signal to release locks and reduce reference counts.

These are the key interfaces used by clients of the framework: SomeApplicationClass AutoCloseable void close() ByteBufferSharing ByteBuffer getBuffer()ByteBuffer expandWriteBufferIfNeeded(int newCapacity)ByteBuffer expandReadBufferIfNeeded(int newCapacity) ByteBufferVendor ByteBufferVendor(ByteBuffer bufferArg, BufferType bufferType, BufferPool bufferPool)ByteBufferSharing open()ByteBufferSharing open(long time, TimeUnit unit)void destruct() ByteBuffer vendor sharing derivedassociationIn the diagram SomeApplicationClass is a class outside the framework, that needs shared ByteBuffers. SomeApplication class has a field holding a ByteBufferVendor. The object referenced by that field is constructed by SomeApplicationClass and when that class is done with the ByteBuffer it calls ByteBufferVendor.destruct().

When SomeApplicationClass needs to access the ByteBuffer it calls ByteBufferVendor.open() as part of a try-with-resources variable declaration. Inside the scope of that try-with-resources block, the thread has exclusive access to the ByteBufferSharing object, and to its associated ByteBuffer.

The try-with-resources machinery works because ByteBufferSharing implements AutoCloseable. Within the scope of a try-with-resources block, client code accesses ByteBuffer functionality through the ByteBufferSharing interface:

ByteBufferSharing
ByteBuffer getBuffer()
ByteBuffer expandWriteBufferIfNeeded(int newCapacity)
ByteBuffer expandReadBufferIfNeeded(int newCapacity)

The first method, obviously, is used to get the ByteBuffer. In the context of try-with-resources, you have exclusive access to the ByteBuffer so you needn't worry about other threads preempting you.

The other two methods are for expanding the ByteBuffer.

In the next section we'll describe some framework internals.

Internals

The ByteBufferSharingInternalImpl class (a private, static internal class of ByteBufferVendor) is the main implementation of the "resource" class just mentioned.

The next class diagram (below) expands on the client view depicted in the first diagram. The dotted association between ByteBufferVendor and ByteBuffer is labeled "derived association". That's meant to convey the fact that the whole point of the vendor is that it mediates access to a ByteBuffer. In UML terms, there is a derived association between the two, even though you won't see an explicit (direct) object reference in the code. PlantUML 1.2023.12 <b>This version of PlantUML is 272 days old, so you should<b>consider upgrading from https://plantuml.com/download [From string (line 19) ] @startumlinterface AutoCloseable {void close()}interface ByteBufferSharing extends AutoCloseable {ByteBuffer getBuffer()ByteBuffer expandWriteBufferIfNeeded(int newCapacity)ByteBuffer expandReadBufferIfNeeded(int newCapacity)}class ByteBufferVendor {ByteBufferVendor(ByteBuffer bufferArg, BufferType bufferType, BufferPool bufferPool)ByteBufferSharing open()ByteBufferSharing open(long time, TimeUnit unit)void destruct()}interface ByteBufferVendor.ByteBufferSharingInternal extends ByteBufferSharing {void releaseBuffer()}Bad hierarchy for class ByteBufferVendor.ByteBufferSharingInternal

Following the Rules

A "resource" object returned from a ByteBufferSharing open() must be assigned in a try-with-resources variable declaration.

After the resource reference is captured this way, you are ensured that when control leaves that block (either through normal flow-of-control, or via an exception), all of the lock and reference-counting bookkeeping will be handled properly.

It's ok to pass a resource reference to a method so long as you are sure that method (and methods it calls) do not allow the reference value to escape. But it is, in general, not ok to return a resource reference from a method (caveats in the Writing Your Own Resource-Returning Methods section below). An escape happens when the reference value:

If you are careful to avoid the first two issues above, then it's ok to pass a resource reference to another method. The resource is available for the lifetime of the method call. And you can be sure that the reference value has not leaked.

Ownership and Reference Counting

A ByteBufferSharing object is typically a field of some object that "owns" the underlying (logical) ByteBuffer. When the owning object is done with that (logical) ByteBuffer forever, it calls destruct() on the ByteBufferSharing. This is necessary because the ByteBuffer must be explicitly returned to a pool.

A ByteBufferSharing is constructed with a reference count of 1. A try-with-resources block will temporarily increment the count by 1 (and will decrement it by 1 at the end of the block). Nested try-with-resources blocks are just fine and result in an extra reference count for each level of nesting.

So one obvious scenario is:

owning object constructs a ByteBufferSharing and reference count is 1

try-with-resources entered and reference count is 2

try-with-resources entered and reference count is 3

try-with-resources block ends and reference count is 2

try-with-resources block ends and reference count is 1

owning object calls destruct() and reference count is 0, and ByteBuffer is returned to its pool

But the framework is explicitly designed to support this scenario too:

Thread A: owning object constructs a ByteBufferSharing and reference count is 1

Thread B: try-with-resources entered and reference count is 2

Thread B: try-with-resources block ends and reference count is 1

Thread C: try-with-resources entered and reference count is 2

Thread A: owning object calls destruct() and reference count is 1, and ByteBuffer is NOT YET RETURNED TO POOL

Thread C: try-with-resources block ends and reference count is 0, NOW the ByteBuffer can be returned to its pool

Writing Your Own Resource-Returning Methods

The ByteBufferSharing open() methods are special. They return a ByteBufferSharing object that constitutes a capability object—if you have the object, you can always perform the operation (in this case the operation is the various methods on the ByteBufferSharing interface).

In practice, you may want to write your own method that returns a ByteBufferSharing. That's fine so long as callers of your new method follow the same rules they'd follow for open() (see the Following the Rules section above).

But you have to be careful when implementing your method. You'd like to ensure that callers of your method don't risk losing the resource between your method returning, and the resource reference value being assigned in the caller's try-with-resources.

Do not do this:

WRONG Method Returning ByteBufferSharing
ByteBufferSharing WRONG() throws IOException {
  try (final ByteBufferSharing sharing = vendor.open()) {

    // +1 net reference count

    // access ByteBuffer through sharing reference here

    // NEVER DO THIS: you risk the reference count dropping to zero
    // before the caller can bind the reference value in its own
    // try-with-resources block
    return sharing
  }
 
  // +0 net reference count--buffer is potentially eligible
  //    for return to pool before caller can access it!
}

Instead, write your method like this:

Your Method Returning ByteBufferSharing
ByteBufferSharing foo() throws IOException {
  try (final ByteBufferSharing sharing = vendor.open()) {

    // +1 net reference count

    // access ByteBuffer through sharing reference here

    // now call open() again to increment reference count for caller
    // +2 net reference count
    return outputBufferVendor.open();
  }
  // +1 net reference count--ensures buffer is available to
  //    caller's try-with-resources variable declaration
}

The calling code calls foo() like this:

Calling Code
try (final ByteBufferSharing sharing = foo()) {

  // access ByteBuffer through sharing reference here

}

Implementer's Notes

When you first encounter ByteBufferVendor.ByteBufferSharingInternalImpl you may wonder why we chose to have both a counter and an isDestructed flag. Couldn't we have just allowed a count of zero to trigger destruction?

The reason we needed both is apparent if you look at the second example in the Ownership and Reference Counting section. We designed the framework so that the ultimate owner of the resource, the object that has a field of type ByteBufferSharing, can "go away" (and call destruct()) while the byte buffer is still in use by other threads. When that happens we set the isDestructed flag and disallow further open() calls. But, critically, we allow an in-flight try-with-resources block to continue its work. We avoid returning the buffer to the pool until the resource owner has destruct()ed the resource and all try-with-resources blocks have finished.

  • No labels