C# and Java still do linking, it just happens dynamically at runtime. That’s part of why startup time is slower in those languages, and why performance can be less predictable.
The main difference between linkers for native binaries and linking in IL-based languages is that native binary linking involves resolving memory addresses at build time. In the object files that are being linked, memory addresses are typically 0-relative to whatever section they’re in within that file. When you combine a bunch of object files together, you have to adjust the addresses so they can live together in the same address space. Object file A and B both might use addresses 0-10, but when they’re linked together, the linker will arrange it so that e.g. A uses 0-10 and B uses 11-21. That’s just a bit of simple offset arithmetic. And if both reference the same non-local symbol, it will be arranged so that both refer to the same memory address.
The IL-based languages retain all the relevant symbol information at runtime, which allows for a lot of flexibility at the cost of some performance - i.e. runtime lookups. This is typically optimized by caching the address after the first lookup, or if JIT compilation is occurring, embedding the relocated addresses in generated code.
The linker UX issues you ran into were mostly a function of the state of the art at the time, though. Languages like Go and Rust do native linking nowadays in a way that users barely notice. IL-based languages had a better linking UX partly because they were forced to - linking problems at runtime do still occur, e.g. “class not found”, but if linking in general had been a common problem for users at runtime instead of developers at build time, those languages would have struggled to get adoption.
Go and Rust are subject to linking too, Rust just happens to have a saner system which deals with it under the hood. It also goes through the same tooling C and C++ do and the subsequent object files may also need to be linked before producing a binary. Java and .NET's loading system are different since JVM uses loading at class granularity based on classpath whilst .NET uses assemblies, with Java, to my knowledge, moving towards modules which are similar a couple decades later (to also improve its startup latency). .NET's assembly system was made to directly address the pains of header/source file compilation and linking issues well-understood even back in the late 90s.
Java modules have nothing to do with that, rather not all packages are supposed to be public rather sub-packages as way to have clean implementations, but given the granularity, many developers end up relying on internals that were designed only for consumption from public APIs.
.NET Assemblies suffer from the same, unless you make use of some tricks like InternalsVisibleTo attribute.
During the .NET 1.0 days there was the idea to have components, for a role similar to how Java modules have come to fulfill, but it never took off, and the idea was confusing as many developers usually thought they related to COM, when they heard "components" alongside .NET.
The main difference between linkers for native binaries and linking in IL-based languages is that native binary linking involves resolving memory addresses at build time. In the object files that are being linked, memory addresses are typically 0-relative to whatever section they’re in within that file. When you combine a bunch of object files together, you have to adjust the addresses so they can live together in the same address space. Object file A and B both might use addresses 0-10, but when they’re linked together, the linker will arrange it so that e.g. A uses 0-10 and B uses 11-21. That’s just a bit of simple offset arithmetic. And if both reference the same non-local symbol, it will be arranged so that both refer to the same memory address.
The IL-based languages retain all the relevant symbol information at runtime, which allows for a lot of flexibility at the cost of some performance - i.e. runtime lookups. This is typically optimized by caching the address after the first lookup, or if JIT compilation is occurring, embedding the relocated addresses in generated code.
The linker UX issues you ran into were mostly a function of the state of the art at the time, though. Languages like Go and Rust do native linking nowadays in a way that users barely notice. IL-based languages had a better linking UX partly because they were forced to - linking problems at runtime do still occur, e.g. “class not found”, but if linking in general had been a common problem for users at runtime instead of developers at build time, those languages would have struggled to get adoption.