I've been working with Java as a main language for over a year now, and have come to the realization that the biggest problem people have with Java isn't the language itself, but every other thing you need to make enterprise software with Java. As I've also worked a lot with C#, I'll draw some parallels between the two.
Spring
Spring is a big beast in the Java universe, and it's hard to find replacements for it unless you start stringing together a bundle of different third-party libraries that handle one of Spring's features each. Spring mainly used to handle dependency injection and API server creation. Apart from being very resource hungry and taking a lot of time to start up, dependency injection is usually done through convention, meaning it checks for interfaces and classes with names that follow a specific convention, forcing you to use this convention. Configuring this is either impossible or very difficult, so you end up hacking a bit with your names if you want to override this. One advantage of Spring is in routing, which doesn't use convention unlike C#.
In the C# world, Spring has essentially been replaced by .NET Core, which offers the same API server creation, dependency injection but also handles server-side rendering through Razor (which requires a library like Wicket or Struts for the equivalent in Java). The main advantage I find though, is that the dependency injection for C# uses a configuration basis instead of convention. This makes it easier to handle specific cases of dependency injection. Although the MVC model in .NET Core uses convention, overriding this convention is actually pretty simple.
Database queries
Spring also offer JdbcTemplate to interact with your database, or you can use another library, Hibernate, if you want a higher language database access. For both of these, you often end up writing SQL code textually, at least for some queries, which couples your application to the database you're using, be it MySQL, SQLite or other. If you want to have tests running on another database (such as H2), chances are some of the SQL code you have won't work with it and you'll end up either having to write a different query for each implementation or writing a less efficient or readable query that can handle both implementations.
C# on the hand introduces EntityFramework (and EntityFrameworkCore). This library offers a wide variety of implementations for SQL database, and the code you write is implementation-agnostic since it translates LINQ into SQL code dependending on the implementation. This makes it very easy to change between a production database and a unit test database without having to change the code.
Ant/Maven/Gradle
Since we tend to not want to write stuff others may have written better, we want the possibility to just get an external library and use it in our code. Since Java doesn't have a dependency management system, your choices are to copy the library into your project and update it manually or to rely on an external tool to do the downloading whenever you need it. Your choices of external tool here are usually Maven or Gradle, but there are a lot more, each with their own configuration file language and template and way to invoke it. These tool also handle compilation for large multiple package projects, and other tools also handle this part only like Ant.
All these different tools mean that you can't have a unified and approved way of handling your compilation and since each tool is developped by different people, may have different bugs and finding information for a tool is often mixed up with information for another tool (Maven and Gradle being the two main culprits here). Add to this the fact that you may even have to write scripts on top of the tool (Gradle Wrapper) to get the tool to work for you.
Since java doesn't have files to define packages and everything is done through folder structure, each of these tool will define a separate configuration file for each package containing the required third-party dependencies.
If we compare this to C#, there is only one unified way to compile and include dependencies and it's handle by a single tool. C# forces you to have a file for each package (project) in your project (solution) which defines all the included files in the package and each required dependency, between projects as well as third-party libraries. There's a single compilation tool to handle everything so finding information about it is very easy.
JVM
Although the JVM offers the possibility to easily port your application to basically any processor architecture, it comes with a large performance cost, especially when starting up an application. This doesn't really affect the normal operation of your API server, but does add a lot of overhead if you restart your application often as when you're doing unt and integration tests. The other problem this causes with unit tests is that you need to create an application whenever you want to run a suite of tests. Although this is done through your testing framework (such as JUnit or TestNG), it means you don't have some of the features C# offers for unit testing such as running a single test or suite of tests easily, restarting only the failed tests and having all that tied to an external running engine which can run your tests in the background when you modify code.
Conventions
Everything I discussed above, plus all the documentation you find online and in code your colleagues wrote before you all force you into using conventions which I particularly dislike. Although some of these are also found in C#, I feel like Java forces you into them a lot more. I'll go over a couple of them here:
JavaBeans
This is I think the biggest culprit for poor design in Java applications. You are forced to write data classes with essentially no domain logic within them. These objects must have getters for all properties and setters for most (if not all) of them as well. This creates immense boilerplate code for a lot of classes, making them unreadable pretty quickly. Since everything is public in these objects, it's pretty hard to actually enforce some domain separation. It also forces you to have an empty constructor so that engines can create these objects and set properties after, which means you can potentially create an object in an invalid state. These objects end up being usable as a simple object with public properties and no constructor, which goes against domain separation as in Domain Driven Design.
DAO
The problem with this one is how people use it, yet again going against Domain Driven Design in favour of a layered architecture. In a typical domain repository, you would return domain objects for your services to use directly, but in DAOs, you usually return a database representation of your object, for your service to then translate into a domain object (adding translation logic which belongs to the repository inside your service). And then often it just translates it property for property into a JavaBean which doesn't add anything to it. All this works together to subtly force you into just using the DAO directly with the entities directly and never touch the services and Beans. If you then ever want to move your entities out of that monolith and into microservices let's say, you're going to have a hard time removing all the references to that DAO and entity and replacing them with the Beans wherever necessary.
Interfaces and implementations
All too often, mostly because of the Spring dependency injection conventions, you end up having a single interface with a single implementation. The interface will have a valid name and then it's only implementation will just append Impl
at the end. Even if you try to put the interface in a different package for access purposes, it's just useless since the implementation isn't really hidden. This usually breaks the purpose and ownership of interfaces. An interface belongs to the one using it and will have a name and method names that reflect that, the implementation will then probably have it's own language, but it won't be visible to the user of the interface. Java tends to revert this pattern and make generic interfaces with a single implementation, basically making it just a collection of classes talking to each other without the need for interfaces. I've talked about this in previous posts, but if your only implementation for an interface has the same name, it's a smell that something isn't right. Chances are a lot of those methods aren't used outside of tests but you can't know that.
Code generation
Since Java is a very verbose language, and using conventions like JavaBeans forces you to write a lot of boilerplate code, a lot of code generation tools have surfaced to circumvent having to write so much code. The major library for this is Lombok. Although I love their annotations, especially for creating object builders, my biggest problem with these is that they often cause compilation problems because a modified class hasn't been regenerated yet or is using a cached version or something similar. This is especially noticeable when writing code in IDEs since they can't find a new property for your object until you compile which can become very cumbersome when you're modifying multiple different classes at the same time or are using TDD.
Conclusion
As mentioned, the biggest problem with Java isn't the actual language, it's everything around it: JVM, Ant/Maven/Gradle, Spring, conventions and code generation. Java (and other JVM languages such as Kotlin, Scala and Clojure) offer a lot of nice features and ease of writting (especially with the newer version of Java) some good software. You just need to make sure you don't fall into the tools and conventions pitfalls.
As a funny addition, go check out the enterprise version of FizzBuzz, which presents a lot of the patterns I've mentioned today: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpriseEdition.