¿Cómo hacer que ThreadPoolExecutor aumente los subprocesos al máximo antes de hacer cola?

99

Me he sentido frustrado durante algún tiempo con el comportamiento predeterminado ThreadPoolExecutorque respalda los ExecutorServicegrupos de subprocesos que muchos de nosotros usamos. Para citar de los Javadocs:

Si hay más subprocesos que corePoolSize pero menos que maximumPoolSize en ejecución, se creará un nuevo subproceso solo si la cola está llena .

Lo que esto significa es que si define un grupo de subprocesos con el siguiente código, nunca iniciará el segundo subproceso porque LinkedBlockingQueueno tiene límites.

ExecutorService threadPool =
   new ThreadPoolExecutor(1 /*core*/, 50 /*max*/, 60 /*timeout*/,
      TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(/* unlimited queue */));

Solo si tiene una cola limitada y la cola está llena, se iniciarán los subprocesos por encima del número principal. Sospecho que un gran número de programadores junior de Java multiproceso desconocen este comportamiento de ThreadPoolExecutor.

Ahora tengo un caso de uso específico donde esto no es óptimo. Estoy buscando formas, sin escribir mi propia clase de TPE, para solucionarlo.

Mis requisitos son para un servicio web que realice devoluciones de llamadas a un tercero posiblemente no confiable.

  • No quiero hacer la devolución de llamada sincrónicamente con la solicitud web, así que quiero usar un grupo de subprocesos.
  • Por lo general, obtengo un par de estos por minuto, por lo que no quiero tener newFixedThreadPool(...)una gran cantidad de subprocesos que en su mayoría están inactivos.
  • De vez en cuando obtengo una ráfaga de este tráfico y quiero escalar el número de subprocesos a un valor máximo (digamos 50).
  • Necesito hacer un mejor intento para hacer todas las devoluciones de llamada, así que quiero poner en cola las adicionales por encima de 50. No quiero abrumar al resto de mi servidor web usando un newCachedThreadPool().

¿Cómo puedo evitar esta limitación en la ThreadPoolExecutorque la cola debe estar limitada y llena antes de que se inicien más subprocesos? ¿Cómo puedo hacer que inicie más subprocesos antes de poner en cola las tareas?

Editar:

@Flavio hace un buen punto sobre el uso de ThreadPoolExecutor.allowCoreThreadTimeOut(true)para que los hilos principales se agoten y salgan. Lo consideré, pero todavía quería la función de hilos centrales. No quería que la cantidad de subprocesos en el grupo cayera por debajo del tamaño del núcleo si es posible.

gris
fuente
1
Dado que su ejemplo crea un máximo de 10 subprocesos, ¿hay algún ahorro real en el uso de algo que crece / se reduce en un grupo de subprocesos de tamaño fijo?
bstempi
Buen punto @bstempi. El número fue algo arbitrario. Lo he aumentado en la pregunta a 50. No estoy seguro de cuántos subprocesos simultáneos quiero que funcionen ahora que tengo esta solución.
Gray
1
¡Oh, maldito! 10 votos a favor si pudiera aquí, exactamente la misma posición en la que estoy.
Eugene

Respuestas:

50

¿Cómo puedo evitar esta limitación en la ThreadPoolExecutorque la cola debe estar limitada y llena antes de que se inicien más subprocesos?

Creo que finalmente he encontrado una solución algo elegante (tal vez un poco hacky) para esta limitación con ThreadPoolExecutor. Implica extenderlo LinkedBlockingQueuepara que vuelva falsepara queue.offer(...)cuando ya hay algunas tareas en cola. Si los subprocesos actuales no se mantienen al día con las tareas en cola, el TPE agregará subprocesos adicionales. Si el grupo ya está en el número máximo de subprocesos, RejectedExecutionHandlerse llamará al. Es el controlador el que luego ingresa put(...)a la cola.

Ciertamente es extraño escribir una cola en la que offer(...)pueda regresar falsey put()nunca se bloquee, así que esa es la parte del truco. Pero esto funciona bien con el uso de la cola por parte de TPE, por lo que no veo ningún problema al hacer esto.

Aquí está el código:

// extend LinkedBlockingQueue to force offer() to return false conditionally
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>() {
    private static final long serialVersionUID = -6903933921423432194L;
    @Override
    public boolean offer(Runnable e) {
        // Offer it to the queue if there is 0 items already queued, else
        // return false so the TPE will add another thread. If we return false
        // and max threads have been reached then the RejectedExecutionHandler
        // will be called which will do the put into the queue.
        if (size() == 0) {
            return super.offer(e);
        } else {
            return false;
        }
    }
};
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1 /*core*/, 50 /*max*/,
        60 /*secs*/, TimeUnit.SECONDS, queue);
threadPool.setRejectedExecutionHandler(new RejectedExecutionHandler() {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        try {
            // This does the actual put into the queue. Once the max threads
            //  have been reached, the tasks will then queue up.
            executor.getQueue().put(r);
            // we do this after the put() to stop race conditions
            if (executor.isShutdown()) {
                throw new RejectedExecutionException(
                    "Task " + r + " rejected from " + e);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }
    }
});

Con este mecanismo, cuando envío tareas a la cola, la ThreadPoolExecutorvoluntad:

  1. Escale el número de subprocesos hasta el tamaño del núcleo inicialmente (aquí 1).
  2. Ofrézcalo a la cola. Si la cola está vacía, se pondrá en cola para ser manejada por los subprocesos existentes.
  3. Si la cola ya tiene 1 o más elementos, offer(...)devolverá falso.
  4. Si se devuelve falso, aumente la cantidad de subprocesos en el grupo hasta que alcancen el número máximo (aquí 50).
  5. Si está al máximo, entonces llama al RejectedExecutionHandler
  6. Los RejectedExecutionHandlerpone entonces la tarea en la cola para ser procesados por el primer hilo disponible en orden FIFO.

Aunque en mi código de ejemplo anterior, la cola no está delimitada, también puede definirla como una cola delimitada. Por ejemplo, si agrega una capacidad de 1000 al, LinkedBlockingQueueentonces:

  1. escalar los hilos al máximo
  2. luego haga cola hasta que esté llena con 1000 tareas
  3. luego bloquee a la persona que llama hasta que haya espacio disponible para la cola.

Además, si necesita usar offer(...)en el RejectedExecutionHandler, puede usar el offer(E, long, TimeUnit)método en su lugar con Long.MAX_VALUEel tiempo de espera.

Advertencia:

Si espera que las tareas se agreguen al ejecutor después de que se haya cerrado, entonces es posible que desee ser más inteligente al RejectedExecutionExceptioneliminar nuestra costumbre RejectedExecutionHandlercuando el servicio del ejecutor se haya cerrado. Gracias a @RaduToader por señalar esto.

Editar:

Otro ajuste a esta respuesta podría ser preguntar al TPE si hay subprocesos inactivos y solo poner el elemento en cola si es así. Tendría que hacer una clase verdadera para esto y agregarle un ourQueue.setThreadPoolExecutor(tpe);método.

Entonces su offer(...)método podría verse así:

  1. Verifique si el tpe.getPoolSize() == tpe.getMaximumPoolSize()en cuyo caso simplemente llame super.offer(...).
  2. De lo contrario tpe.getPoolSize() > tpe.getActiveCount(), llame super.offer(...)porque parece que hay hilos inactivos.
  3. De lo contrario, vuelva falsea bifurcar otro hilo.

Tal vez esto:

int poolSize = tpe.getPoolSize();
int maximumPoolSize = tpe.getMaximumPoolSize();
if (poolSize >= maximumPoolSize || poolSize > tpe.getActiveCount()) {
    return super.offer(e);
} else {
    return false;
}

Tenga en cuenta que los métodos de obtención en TPE son costosos ya que acceden a volatilecampos o (en el caso de getActiveCount()) bloquean el TPE y recorren la lista de subprocesos. Además, aquí hay condiciones de carrera que pueden hacer que una tarea se ponga en cola de forma incorrecta o que se bifurque otro hilo cuando había un hilo inactivo.

gris
fuente
También luché con el mismo problema, terminé anulando el método de ejecución. Pero esta es realmente una buena solución. :)
Batty
Por mucho que no me guste la idea de romper el contrato Queuepara lograr esto, ciertamente no estás solo en tu idea: groovy-programming.com/post/26923146865
bstempi
3
¿No te parece una rareza el hecho de que el primer par de tareas se pondrán en cola y solo después de que aparezcan nuevos hilos? Por ejemplo, si su hilo principal está ocupado con una única tarea de larga duración y llama execute(runnable), entonces runnablesimplemente se agrega a la cola. Si llama execute(secondRunnable), secondRunnablese agrega a la cola. Pero ahora, si llama execute(thirdRunnable), thirdRunnablese ejecutará en un nuevo hilo. La runnabley secondRunnablesolo se ejecutan una vez thirdRunnable(o la tarea original de larga duración) han finalizado.
Robert Tupelo-Schneck
1
Sí, Robert tiene razón, en un entorno con muchos subprocesos múltiples, la cola a veces crece mientras hay subprocesos libres para usar. La solución debajo de la cual extiende TPE - funciona mucho mejor. Creo que la sugerencia de Robert debe marcarse como respuesta, aunque el truco anterior es interesante
quiero saberlo todo
1
El "RejectedExecutionHandler" ayudó al ejecutor en el cierre. Ahora se ve obligado a usar shutdownNow () ya que shutdown () no evita que se agreguen nuevas tareas (debido a la solicitud)
Radu Toader
28

Set tamaño del núcleo y el tamaño máximo para el mismo valor, y permiten hilos de núcleo para ser retirados de la piscina con allowCoreThreadTimeOut(true).

Flavio
fuente
+1 Sí, pensé en eso, pero todavía quería tener la función de hilos centrales. No quería que el grupo de subprocesos pasara a 0 subprocesos durante los períodos inactivos. Editaré mi pregunta para señalar eso. Pero excelente punto.
Gray
¡Gracias! Es la forma más sencilla de hacerlo.
Dmitry Ovchinnikov
28

Ya tengo otras dos respuestas a esta pregunta, pero sospecho que esta es la mejor.

Se basa en la técnica de la respuesta actualmente aceptada , a saber:

  1. Anula el offer()método de la cola para (a veces) devolver falso,
  2. lo que hace ThreadPoolExecutorque genere un nuevo hilo o rechace la tarea, y
  3. configure el RejectedExecutionHandlerpara poner en cola la tarea en caso de rechazo.

El problema es cuándo offer()debe devolver falso. La respuesta actualmente aceptada devuelve falso cuando la cola tiene un par de tareas, pero como señalé en mi comentario allí, esto causa efectos indeseables. Alternativamente, si siempre devuelve falso, seguirá generando nuevos hilos incluso cuando tenga hilos esperando en la cola.

La solución es usar Java 7 LinkedTransferQueuey tener offer()call tryTransfer(). Cuando hay un hilo consumidor en espera, la tarea simplemente se pasará a ese hilo. De lo contrario, offer()devolverá falso y ThreadPoolExecutorgenerará un nuevo hilo.

    BlockingQueue<Runnable> queue = new LinkedTransferQueue<Runnable>() {
        @Override
        public boolean offer(Runnable e) {
            return tryTransfer(e);
        }
    };
    ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 50, 60, TimeUnit.SECONDS, queue);
    threadPool.setRejectedExecutionHandler(new RejectedExecutionHandler() {
        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            try {
                executor.getQueue().put(r);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    });
Robert Tupelo-Schneck
fuente
Tengo que estar de acuerdo, esto me parece más limpio. El único inconveniente de la solución es que LinkedTransferQueue no tiene límites, por lo que no obtiene una cola de tareas limitada por capacidad sin trabajo adicional.
Yeroc
Hay un problema cuando la piscina crece al tamaño máximo. Digamos que el grupo escalado hasta el tamaño máximo y cada hilo está ejecutando una tarea, cuando se puede ejecutar, esta oferta implícita devolverá falso y ThreadPoolExecutor intenta agregar el hilo de trabajo, pero el grupo ya alcanzó su máximo, por lo que el ejecutable simplemente será rechazado. De acuerdo con el RepelenteExceHandler que escribió, se ofrecerá nuevamente en la cola, lo que dará como resultado que esta danza del mono vuelva a ocurrir desde el principio.
Sudheera
1
@Sudheera Creo que estás equivocado. queue.offer(), debido a que realmente está llamando LinkedTransferQueue.tryTransfer(), devolverá falso y no pondrá en cola la tarea. Sin embargo, las RejectedExecutionHandlerllamadas queue.put(), que no fallan y ponen en cola la tarea.
Robert Tupelo-Schneck
1
@ RobertTupelo-Schneck extremadamente útil y agradable!
Eugene
1
@ RobertTupelo-Schneck ¡Funciona de maravilla! No sé por qué no hay algo así fuera de la caja en Java
Georgi Peev
7

Nota: ahora prefiero y recomiendo mi otra respuesta .

Aquí hay una versión que me parece mucho más sencilla: aumente el corePoolSize (hasta el límite de maximumPoolSize) cada vez que se ejecute una nueva tarea, luego disminuya el corePoolSize (hasta el límite del "tamaño de grupo central" especificado por el usuario) siempre que un la tarea se completa.

Para decirlo de otra manera, realice un seguimiento del número de tareas en ejecución o en cola, y asegúrese de que corePoolSize sea igual al número de tareas siempre que se encuentre entre el "tamaño del grupo de núcleos" especificado por el usuario y el maximumPoolSize.

public class GrowBeforeQueueThreadPoolExecutor extends ThreadPoolExecutor {
    private int userSpecifiedCorePoolSize;
    private int taskCount;

    public GrowBeforeQueueThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
        userSpecifiedCorePoolSize = corePoolSize;
    }

    @Override
    public void execute(Runnable runnable) {
        synchronized (this) {
            taskCount++;
            setCorePoolSizeToTaskCountWithinBounds();
        }
        super.execute(runnable);
    }

    @Override
    protected void afterExecute(Runnable runnable, Throwable throwable) {
        super.afterExecute(runnable, throwable);
        synchronized (this) {
            taskCount--;
            setCorePoolSizeToTaskCountWithinBounds();
        }
    }

    private void setCorePoolSizeToTaskCountWithinBounds() {
        int threads = taskCount;
        if (threads < userSpecifiedCorePoolSize) threads = userSpecifiedCorePoolSize;
        if (threads > getMaximumPoolSize()) threads = getMaximumPoolSize();
        setCorePoolSize(threads);
    }
}

Tal como está escrito, la clase no admite cambiar el corePoolSize o maximumPoolSize especificado por el usuario después de la construcción, y no admite la manipulación de la cola de trabajo directamente o mediante remove()o purge().

Robert Tupelo-Schneck
fuente
Me gusta excepto por los synchronizedbloques. ¿Puede llamar a la cola para obtener el número de tareas? ¿O tal vez usar un AtomicInteger?
Gray
Quería evitarlos, pero el problema es este. Si hay una cantidad de execute()llamadas en subprocesos separados, cada una va a (1) calcular cuántos subprocesos se necesitan, (2) setCorePoolSizea ese número y (3) llamar super.execute(). Si los pasos (1) y (2) no están sincronizados, no estoy seguro de cómo evitar un pedido desafortunado en el que establece el tamaño del grupo principal en un número menor después de un número mayor. Con acceso directo al campo de superclase, esto se podría hacer usando comparar y establecer en su lugar, pero no veo una forma limpia de hacerlo en una subclase sin sincronización.
Robert Tupelo-Schneck
Creo que las sanciones por esa condición de carrera son relativamente bajas siempre que el taskCountcampo sea válido (es decir, a AtomicInteger). Si dos subprocesos recalculan el tamaño del grupo inmediatamente uno después del otro, debería obtener los valores adecuados. Si el segundo encoge los hilos centrales, entonces debe haber visto una caída en la cola o algo así.
Gray
1
Lamentablemente, creo que es peor que eso. Suponga que las tareas 10 y 11 llaman execute(). Cada uno llamará atomicTaskCount.incrementAndGet()y obtendrán 10 y 11 respectivamente. Pero sin sincronización (por encima de obtener el recuento de tareas y establecer el tamaño del grupo central), podría obtener (1) la tarea 11 establece el tamaño del grupo central en 11, (2) la tarea 10 establece el tamaño del grupo central en 10, (3) la tarea 10 llama super.execute(), (4) la tarea 11 llama super.execute()y se pone en cola.
Robert Tupelo-Schneck
2
Le di a esta solución algunas pruebas serias y claramente es la mejor. En un entorno altamente multiproceso, a veces se pondrá en cola cuando hay subprocesos libres (debido a la naturaleza TPE.execute de subprocesos libres), pero ocurre raramente, a diferencia de la solución marcada como respuesta, donde la condición de carrera tiene más posibilidades de suceda, por lo que esto sucede prácticamente en cada ejecución de subprocesos múltiples.
Quiero saberlo todo
6

Tenemos una subclase ThreadPoolExecutorque toma un adicional creationThresholdy anula execute.

public void execute(Runnable command) {
    super.execute(command);
    final int poolSize = getPoolSize();
    if (poolSize < getMaximumPoolSize()) {
        if (getQueue().size() > creationThreshold) {
            synchronized (this) {
                setCorePoolSize(poolSize + 1);
                setCorePoolSize(poolSize);
            }
        }
    }
}

tal vez eso también ayude, pero el tuyo se ve más artístico, por supuesto ...

Ralf H
fuente
Interesante. Gracias por esto. De hecho, no sabía que el tamaño del núcleo era mutable.
Gray
Ahora que lo pienso un poco más, esta solución es mejor que la mía en términos de verificar el tamaño de la cola. Modifiqué mi respuesta para que el offer(...)método solo regrese falsecondicionalmente. ¡Gracias!
Gray
4

La respuesta recomendada resuelve solo uno (1) del problema con el grupo de subprocesos JDK:

  1. Los grupos de subprocesos de JDK están predispuestos a las colas. Entonces, en lugar de generar un nuevo hilo, pondrán la tarea en cola. Solo si la cola alcanza su límite, el grupo de subprocesos generará un nuevo subproceso.

  2. El retiro del hilo no ocurre cuando la carga se aligera. Por ejemplo, si tenemos una ráfaga de trabajos que llegan al grupo que hace que el grupo llegue al máximo, seguido de una carga ligera de un máximo de 2 tareas a la vez, el grupo usará todos los subprocesos para atender la carga ligera y evitar el retiro de subprocesos. (solo se necesitarían 2 hilos…)

Insatisfecho con el comportamiento anterior, seguí adelante e implementé un grupo para superar las deficiencias anteriores.

Para resolver 2) El uso de la programación de Lifo resuelve el problema. Esta idea fue presentada por Ben Maurer en la conferencia de aplicación ACM 2015: escala Systems @ Facebook

Entonces nació una nueva implementación:

LifoThreadPoolExecutorSQP

Hasta ahora, esta implementación mejora el rendimiento de ejecución asíncrona para ZEL .

La implementación es capaz de reducir la sobrecarga de cambio de contexto, lo que produce un rendimiento superior para ciertos casos de uso.

Espero eso ayude...

PD: JDK Fork Join Pool implementa ExecutorService y funciona como un grupo de subprocesos "normal", la implementación es eficaz, utiliza la programación de subprocesos LIFO, sin embargo, no hay control sobre el tamaño de la cola interna, el tiempo de espera de retiro ... y, lo más importante, las tareas no pueden ser interrumpido al cancelarlos

usuario2179737
fuente
1
Lástima que esta implementación tenga tantas dependencias externas. Haciéndolo inútil para mí: - /
Martin L.
1
Es un muy buen punto (2º). Desafortunadamente, la implementación no está clara a partir de dependencias externas, pero aún se puede adoptar si lo desea.
Alexey Vlasov
1

Nota: ahora prefiero y recomiendo mi otra respuesta .

Tengo otra propuesta, siguiendo la idea original de cambiar la cola para devolver falso. En este, todas las tareas pueden ingresar a la cola, pero siempre que una tarea se pone en cola después execute(), la seguimos con una tarea centinela sin operación que la cola rechaza, lo que hace que se genere un nuevo hilo, que ejecutará la no operación seguida inmediatamente por algo de la cola.

Debido a que los subprocesos de trabajo pueden estar sondeando LinkedBlockingQueue para una nueva tarea, es posible que una tarea se ponga en cola incluso cuando hay un subproceso disponible. Para evitar generar nuevos subprocesos incluso cuando hay subprocesos disponibles, necesitamos realizar un seguimiento de cuántos subprocesos están esperando nuevas tareas en la cola y solo generar un nuevo subproceso cuando hay más tareas en la cola que subprocesos en espera.

final Runnable SENTINEL_NO_OP = new Runnable() { public void run() { } };

final AtomicInteger waitingThreads = new AtomicInteger(0);

BlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>() {
    @Override
    public boolean offer(Runnable e) {
        // offer returning false will cause the executor to spawn a new thread
        if (e == SENTINEL_NO_OP) return size() <= waitingThreads.get();
        else return super.offer(e);
    }

    @Override
    public Runnable poll(long timeout, TimeUnit unit) throws InterruptedException {
        try {
            waitingThreads.incrementAndGet();
            return super.poll(timeout, unit);
        } finally {
            waitingThreads.decrementAndGet();
        }
    }

    @Override
    public Runnable take() throws InterruptedException {
        try {
            waitingThreads.incrementAndGet();
            return super.take();
        } finally {
            waitingThreads.decrementAndGet();
        }
    }
};

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 50, 60, TimeUnit.SECONDS, queue) {
    @Override
    public void execute(Runnable command) {
        super.execute(command);
        if (getQueue().size() > waitingThreads.get()) super.execute(SENTINEL_NO_OP);
    }
};
threadPool.setRejectedExecutionHandler(new RejectedExecutionHandler() {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        if (r == SENTINEL_NO_OP) return;
        else throw new RejectedExecutionException();            
    }
});
Robert Tupelo-Schneck
fuente
0

La mejor solución que se me ocurre es ampliar.

ThreadPoolExecutorofrece algunos métodos de gancho: beforeExecutey afterExecute. En su extensión, podría mantener el uso de una cola limitada para alimentar tareas y una segunda cola ilimitada para manejar el desbordamiento. Cuando alguien llama submit, puede intentar colocar la solicitud en la cola limitada. Si se encuentra con una excepción, simplemente coloque la tarea en su cola de desbordamiento. A continuación, puede utilizar el afterExecutegancho para ver si hay algo en la cola de desbordamiento después de finalizar una tarea. De esta manera, el ejecutor se ocupará primero de las cosas en su cola limitada y automáticamente sacará de esta cola ilimitada cuando el tiempo lo permita.

Parece más trabajo que tu solución, pero al menos no implica dar a las colas comportamientos inesperados. También imagino que hay una mejor manera de verificar el estado de la cola y los subprocesos en lugar de confiar en las excepciones, que son bastante lentas de lanzar.

bstempi
fuente
No me gusta esta solución. Estoy bastante seguro de que ThreadPoolExecutor no fue diseñado para herencia.
scottb
En realidad, hay un ejemplo de una extensión directamente en JavaDoc. Afirman que la mayoría probablemente solo implemente los métodos de gancho, pero le dicen qué más debe tener en cuenta al extender.
bstempi
0

Nota: Para JDK ThreadPoolExecutor, cuando tiene una cola limitada, solo está creando nuevos hilos cuando la oferta devuelve falsa. Puede obtener algo útil con CallerRunsPolicy que crea un poco de BackPressure y las llamadas se ejecutan directamente en el hilo de la llamada.

Necesito que las tareas se ejecuten a partir de subprocesos creados por el grupo y tener una cola ubounded para la programación, mientras que la cantidad de subprocesos dentro del grupo puede crecer o reducirse entre corePoolSize y maximumPoolSize, así que ...

Terminé haciendo una copia completa pasta de ThreadPoolExecutor y cambiar un poco el método ejecuta debido a que por desgracia esto no se podía hacer por extensión (se llama a los métodos privados).

No quería generar nuevos hilos inmediatamente cuando llega una nueva solicitud y todos los hilos están ocupados (porque en general tengo tareas de corta duración). Agregué un umbral, pero siéntase libre de cambiarlo según sus necesidades (tal vez para la mayoría de los IO es mejor eliminar este umbral)

private final AtomicInteger activeWorkers = new AtomicInteger(0);
private volatile double threshold = 0.7d;

protected void beforeExecute(Thread t, Runnable r) {
    activeWorkers.incrementAndGet();
}
protected void afterExecute(Runnable r, Throwable t) {
    activeWorkers.decrementAndGet();
}
public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();

        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }

        if (isRunning(c) && this.workQueue.offer(command)) {
            int recheck = this.ctl.get();
            if (!isRunning(recheck) && this.remove(command)) {
                this.reject(command);
            } else if (workerCountOf(recheck) == 0) {
                this.addWorker((Runnable) null, false);
            }
            //>>change start
            else if (workerCountOf(recheck) < maximumPoolSize //
                && (activeWorkers.get() > workerCountOf(recheck) * threshold
                    || workQueue.size() > workerCountOf(recheck) * threshold)) {
                this.addWorker((Runnable) null, false);
            }
            //<<change end
        } else if (!this.addWorker(command, false)) {
            this.reject(command);
        }
    }
Radu Toader
fuente