diff --git a/src/main/java/io/vertx/core/impl/future/CompositeFutureImpl.java b/src/main/java/io/vertx/core/impl/future/CompositeFutureImpl.java
--- a/src/main/java/io/vertx/core/impl/future/CompositeFutureImpl.java
+++ b/src/main/java/io/vertx/core/impl/future/CompositeFutureImpl.java
@@ -17,6 +17,7 @@
 import io.vertx.core.Handler;
 
 import java.util.function.Function;
+import java.util.stream.Collectors;
 
 /**
  * @author <a href="mailto:julien@julienviet.com">Julien Viet</a>
@@ -188,4 +189,16 @@ public CompositeFuture onSuccess(Handler<CompositeFuture> handler) {
   public CompositeFuture onFailure(Handler<Throwable> handler) {
     return (CompositeFuture)super.onFailure(handler);
   }
+
+  @Override
+  protected void formatValue(Object value, StringBuilder sb) {
+    sb.append('(');
+    for (int i = 0;i < results.length;i++) {
+      if (i > 0) {
+        sb.append(',');
+      }
+      sb.append(results[i]);
+    }
+    sb.append(')');
+  }
 }
diff --git a/src/main/java/io/vertx/core/impl/future/FutureImpl.java b/src/main/java/io/vertx/core/impl/future/FutureImpl.java
--- a/src/main/java/io/vertx/core/impl/future/FutureImpl.java
+++ b/src/main/java/io/vertx/core/impl/future/FutureImpl.java
@@ -19,7 +19,6 @@
 
 import java.util.ArrayList;
 import java.util.Objects;
-import java.util.function.Function;
 
 /**
  * Future implementation.
@@ -208,12 +207,22 @@ public String toString() {
         return "Future{cause=" + ((Throwable)value).getMessage() + "}";
       }
       if (value != null) {
-        return "Future{result=" + (value == NULL_VALUE ? "null" : value) + "}";
+        if (value == NULL_VALUE) {
+          return "Future{result=null}";
+        }
+        StringBuilder sb = new StringBuilder("Future{result=");
+        formatValue(value, sb);
+        sb.append("}");
+        return sb.toString();
       }
       return "Future{unresolved}";
     }
   }
 
+  protected void formatValue(Object value, StringBuilder sb) {
+    sb.append(value);
+  }
+
   private static class ListenerArray<T> extends ArrayList<Listener<T>> implements Listener<T> {
     @Override
     public void onSuccess(T value) {

diff --git a/src/test/java/io/vertx/core/CompositeFutureTest.java b/src/test/java/io/vertx/core/CompositeFutureTest.java
--- a/src/test/java/io/vertx/core/CompositeFutureTest.java
+++ b/src/test/java/io/vertx/core/CompositeFutureTest.java
@@ -509,4 +509,10 @@ public void testCompositeFutureMulti() {
     p2.complete(4);
     assertEquals(2, count.get());
   }
+
+  @Test
+  public void testToString() {
+    assertEquals("Future{result=(Future{result=null},Future{result=null})}", CompositeFuture.all(Future.succeededFuture(), Future.succeededFuture()).toString());
+    assertEquals("Future{result=(Future{result=true},Future{result=false})}", CompositeFuture.all(Future.succeededFuture(true), Future.succeededFuture(false)).toString());
+  }
 }

CompositeFuture toString gives stack overflow for completed composite future
### Version

4.0.0

### Context

I get a stack overflow exception while logging a completed CompositeFuture

### Do you have a reproducer?
Yes:

```
import io.vertx.core.CompositeFuture;
import static io.vertx.core.Future.succeededFuture;
import static java.util.Collections.singletonList;
public class Simple {
    public static void main(String[] args) {
        CompositeFuture compositeFuture = CompositeFuture.all(singletonList(succeededFuture()));
        System.out.println(compositeFuture.toString());
    }
}
```
The code above gives a stack overflow exception 
### Steps to reproduce

Run program above

### Extra
Java 11



does it happen also with vertx 3 ?
No - it works with 3.9.5
The issue seems to be that the .value of  CompositeFutureImpl is a reference to the composite future itself, giving a recursive call.
I would suggest overriding toString to show the count and size instead e.g.  CompositeFutureImpl( 2/5 completed)? But that might be more of a feature request.
I think we can kill 2 birds with one stone here