So scary. I was working on a garbage collection bug for a AAA video game one time for months. It'd crash once a day on a tester machine and we'd add more and more prints to try and narrow it down.
Finally got enough information and realized that the padding of a specific object was wrong (GC expected 16 bytes, object was 12 bytes). This caused dozens of other corruption bugs to disappear that we didn't even think were GC related.
I don't know, but we had a similar bug in OCaml, although in reverse.
Linux/x86-64 expects the stack to always be 16 byte aligned (although the ABI documentation at the time didn't make this assumption very clear). OCaml called into C with a non-aligned stack. GCC-generated code, assuming the stack was 16 byte aligned, used some strange Intel AVX instruction that only works on aligned data, unlike every other Intel instruction ever that can work on any alignment (albeit maybe more slowly).
This manifested itself as rare and totally unreproducible crashes (because stack alignment differed between runs). It was a bit of a nightmare to solve.
The fact that msvc generates the unaligned loads for every avx instruction but gcc didn't gave me so many headaches. Most people worked on PC or Xbox and I was on the Playstation team.
"oh boy, another one of these..."
Yes! It's one of those cases where when you've seen it before and know the catch with the instruction (probably vmovdqa) then you'll immediately recognise it. If you don't know it, it's very very mysterious. Why on earth Intel decided to make a handful of instructions require alignment is also a mystery to me.
Sweet mama speed. Although from what I understand it is more legacy speed cause you're losing all your time to fetching the memory anyway. But when processors were slower it was a meaningful amount.
I hear these stories of black magic bugs and I look back at my 8 year career wondering if I'm even progressing as an engineer. Thrown from one studio to the next and never truly staying long enough to be trusted to investigate such issues.
I have no idea how engineers that started in the 10's or 20's are going to rise up to take over those fro the 90's/00's. So much is abstracted, but games specifically need to understand what's under the hood. Because they can and will hit some of the nastiest edge cases.
The problem is that if you call another function, you won't know whether that function is uses any instructions that require alignment. So in practice, only leaf functions can skip stack alignment. The ABI states that the stack pointer must be aligned to a multiple of 16, plus 8, before any `call` instruction.
Probably a custom one built for an in-house engine in C++ if it's a AAA studio. Alternatively one of the various off-the-shell ones you can #include, though my money is on the former. Note: I am not GP, I'm making educated guesses about what may have happened :)
If C# they'd have been using the MS authored GC. It was probably a custom GC for C++ heaps. Quite a few games do this, it's a smart productivity hack. Unreal uses a simple form of mark/sweep GC for its C++ game state heap.
Unlikely. Though in the case of Unity, this can happen if you fail to uphold memory layout expectations when writing C/C++ code (or if you do something really bad in C#).
There would also be a debugger allowing to reproduce and catch this in an easier way.
They are indeed scary, but one thing that can make them less so is writing them in a high level language. A nice code base to study if you're new to GCs is the GenScavange module of SubstrateVM, a JVM written entirely in Java. Start here:
It gets compiled to native code for use. It's actually a bit of a dialect of Java because of course, you need low level memory and stack access. So you can see at parts there is use of special APIs that get compiled to things like stack allocations, so it can avoid allocating on the heap whilst working with it. Even so the "business logic" of a GC can be easily seen here, especially if you don't know C++.
And is such a long file GitHub can’t even display it. I get collocation but is one file that’s so large it has editor problems as a result, really such a good thing?
Same, I increasingly dislike splitting up code into separate files unless there’s a really good chance each file’s going to be imported/used independently in at least some cases.
I realized I’d made this shift a couple years ago when I had a ~500 line (god, that’s not even that long) single-file program I was very happy with and thought well-organized and someone a fair bit more junior complained that it needed to be split up just because it was long.
Ew. No. Thought a decade or more ago I might have thought the same thing.
>The editor should be able to help you navigate and see the file without the size being a concern at all.
Jetbrains's suite has a very large but reasonable limit (500k lines IIRC) before it simply disables it code analysis/highlighting. You can increase it if you need to, but I imagine they keep that number by default for perfomance reasons.
It’s really an editor problem. Theoretically there could be a world where all code is in one file, and that’s ok because the editor allows you to effortlessly navigate your code via a variety of means. For some this might be using a keyboard, for others more visually inclined, perhaps in Java there is a browsable visual model.
I’m honestly surprised there doesn’t have exist an editor/language that abstracts away the concept of source code files completely. I assume that there is a good chance that in 50 years time we won’t be coding using collections of text files.
I was a Java apologist for a long time and I still think you can write fine software in it (and the JVM is an incredible piece of engineering). But the fact that the class is the _only_ unit of abstraction is just so frustrating to me now (Yegge's Execution in the Kingdom of Nouns makes this criticism in a humorous allegory[1]). Modern Java does mitigate this somewhat. Lambdas provide nice sugar over the inline class-creation you'd have to do of yore. Records make it less of a pain to define POJOs. But I think this fundamental issue is at the root of much of the language's clunkiness.
They were close but unfortunately went too far at being opinionated in solve the issues of using C at the time of Java's inception. Changing this now is Valhalla project which has unknown release date.
Luckily, C# learned from this mistake and made a decision to follow C much closely, having integers as proper primitives and supporting structs since day 1, and only improved since then in this area (JVM needs insane pointer compression and shadow tricks to pack integers to claw back performance, and in .NET "it just works" instead).
I wonder if Cassandra was written in .NET, it wouldn't have such a poor performance (though this can also be showcased by Garnet which beats competition written in C++ and C).
Java’s errors are amongst the best in my experience. Do you prefer Python’s much more esoteric stack traces, or Go’s one liner zero information spam, or the traditional “SegFault”?
Like honestly, which ecosystem has better exceptions/error messages, in the general case? Java pinpoints the exact source of the error with useful context. It’s as good as it can get.
So in the context of a class name `JavaBadAbstractFactoryObjectFactoryException` your problem is on a human side, your colleague using bad class and var names. Language has nothing to do with it. Ruby and C# also have VERY long class names, yet we don't talk about them so much.
Well, compared to what? The alternative to exceptions is usually worse:
1. Error codes. Try debugging Win32 software and have fun working out with all those HRESULTs mean, especially as every Google hit for any given code is just endless support forums with people complaining their computer is broken.
2. Panics. You get an error message no more helpful than an exception, except with no stack trace to help you figure out what's wrong.
3. Segfaults. You get nothing.
Exceptions are the best form of error handling yet invented. Making programmers more helpful is hard, but exceptions have auto-generated error messages that at least tell you where the program died, how it got there, and very often the chain of problems that led to that surface level problem (a particular weakness of error codes where causal chains are often lost). They also often have JavaDocs explaining what they mean.
Although it's not really a weakness of Java or exceptions per se, I think some people don't like them because lazy devs don't bother translating them into something more user friendly (especially for developer tools). There are simple patterns that fix that though. My company makes a CLI developer tool written in Java+Kotlin running on the JVM (see bio) and it defines a dedicated UserError exception type. If an exception bubbles to the top level of the program and it descends from UserError then it's formatted nicely with colors and shown as you'd expect. If it's not then the exception is logged and a crash reporter is started, the user is asked if they'd like to report the crash. The exception details are then printed without the stack trace and polished slightly ("FooBarException: abc" -> "Foo Bar: abc") which often makes the messages good enough that the user can unstick themselves. There are utility methods to catch and rethrow exceptions as user errors when the underlying messages are already good enough, and the product is careful to rethrow manually if the messages need to be improved. There are also utilities to verify that a file/directory exists and if not, throw a special subtype of UserError that yields a spelling corrector [1]
The result is that if you check our homepage, you'll see at least one customer explicitly praised our error message quality! They were very happy to never see a stack trace from a developer tool, only actionable errors that tell you what to do.
So exceptions can definitely yield a great UX. You just have to care and put in a bit of effort.
IntelliJ is another example of a product where the exception UX is highly polished. Its exception reporter can figure out which plugin an error comes from, route error reports automatically, deduplicate errors based on the stack traces, attach files that the exception object advertises and so on.
which effectively looks up error codes in Windows SDK header files (which need not be installed)
~> err 0x80070091
# No results found for hex 0x80070091 / decimal -2147024751
# as an HRESULT: Severity: FAILURE (1), FACILITY_WIN32 (0x7), Code 0x91
# for hex 0x91 / decimal 145
ERROR_DIR_NOT_EMPTY winerror.h
# The directory is not empty.
# 1 matches found for "0x80070091"
~> err 91
# for decimal 91 / hex 0x5b
SET_ENV_VAR_FAILED bugcodes.h
NMERR_DISCARD_FRAME netmon.h
LDAP_CONNECT_ERROR winldap.h
# for hex 0x91 / decimal 145
WIN32K_INIT_OR_RIT_FAILURE bugcodes.h
ERROR_DIR_NOT_EMPTY winerror.h
# The directory is not empty.
# as an HRESULT: Severity: SUCCESS (0), FACILITY_NULL (0x0), Code 0x91
# for hex 0x91 / decimal 145
ERROR_DIR_NOT_EMPTY winerror.h
# The directory is not empty.
# 6 matches found for "91"
which is frequently better than nothing, grep, or Google, especially for small integer error codes (and, in the case of Google, for any HRESULT Windows Update or the Microsoft Store has ever returned).
Sure, but GP was criticizing Java, not Exceptions.
I find that JavaScript, Ruby and C# are all easier to debut in practice than Java, and all three use exceptions. IMO this is mainly due to what idiomatic Java looks like and how libraries/frameworks are structured.
Hm, we've had different experiences then. My experience with languages like JS/Ruby is that exceptions are often obfuscated (for js) and will frequently be meaningless type errors like "foo is not an object". I didn't use C# but I'd guess it's similar to Java. After all, here's an exception factory library for C# (https://github.com/scionwest/ExceptionFactory).
What most people seem to complain about when they say "Java" is actually dependency injection frameworks, not the language or even regular libraries (which typically don't use them). I also don't like Guice so can sympathise, but DI frameworks vary in quality a lot, and you don't have to abuse them. Modern frameworks are based on code generation and check much more at compile time. My app uses a compile time DI framework to set up the build task graph, but doesn't have a factory bean anywhere and has never yielded strange errors about factories that were hard to debug.
Yeah but I don't know if DI (ab)usage is really idiomatic Java. I've written lots of Java over the years and only rarely used DI frameworks. Libraries never use them. When I did use them they've been fairly restrained and most of the code you wouldn't notice it. It might be idiomatic if all you do is write web servers.
I don't love it either. But let's be brutally honest here: the single inflection point where the world of software shifted from "usually buggy, routine crashes" to "usually works, presumptively secure" was the arrival and gradual embrace of Java and its managed runtime in the late 90's and early 2000's.
Were there other technologies that do the same thing? Sure. Were they better? In some cases. Was Java really the "first" by whatever metric? No.
Clearly you haven’t been part of the group that had to track down where Java could be installed in a jumble of workstations and servers, because it was installed as part of an application that may or may not have advertised Java as its third-or-fourth level dependencies.
It also seems you are forgetting just how bad early Java was, from a programming and user standpoint. Java errors like NullPointerException became a meme because they were so prevalent in user space, and required so much energy and time to get things working. As a result, people typically refused to update any part of Java once their app was working, to avoid breaking things. And that was _if_ the install was registered with a package manager or Windows Update so updates could be reported.
As someone who supports Java apps, I don't think the errors have improved. They are completely nonsense to non-programmers, and they rarely ever have good documentation. Most only show up in old SO post, or application specific forums, and you have to sift through a ton of crap just to hopefully find an explanation or fix.
But what is it about Java errors specifically that make this a "java" problem as opposed to a "bad developers" problem? What makes "FileNotFoundException" more non-sensical to non programmers than "SEGFAULT" or "Error -256". Maybe you could argue that the stack traces and "let it bubble" behavior is useful to developers and as a result developers don't write better error handling to translate those errors into better user facing errors, but again that feels like a developer problem, not a language problem.
What does it have to do with java though? It’s a programming language, not general artificial intelligence. If you wrote errors that suck, and then didn’t bother encapsulating it in some sane form before it hits the user, it’s entirely on you.
Java is decent enough that even when that happens, the log/stderr will at least be actionable by devs, which is much more than many other ecosystem can say.
> They are completely nonsense to non-programmers, and they rarely ever have good documentation
This applies the same to other "popular" languages like Javascript i.e. NodeJs. I'd argue Java apps being supported by large corporations actually have better documentation.
> Most only show up in old SO post, or application specific forums
And a lot of "popular" issues only show up in a hidden github issues comment that may or may not be relevant anymore.
Everything you've described all happens in many other languages today. Python is personally one of my more prominent nemeses, as the ML bubble marches forward; I can't even tell you how many venvs I've got with various binaries for Nvidia interfaces along for the ride. The .NET family still has that terrible library installer for older solutions that lingers like a bad odor.
And let's not forget the joy that is Python's many, many esoteric values due to rampant signature changes in dependency hell. Whoops! That library that just got accidentally updated renamed a critical function 2 layers deep in the 70+ transitive dependency hierarchy, and threw an error; too bad it didn't manifest before you spent 10 hours of processing time.
.NET is not a subject to the insanity that is managing host-installed dependencies in and versions of Python.
For SDKs themselves, they are installed in a canonical path, and only a single (latest) executable exists in path, called "muxer", also working as front-end for all commands. It then can choose corresponding SDK and runtime dependencies installed in subfolders depending on a situation. You can easily do sudo apt install dotnet-sdk-8/7/6.0 in any order and it will "just work".
> It’s almost impossible to shoot yourself in the foot.
Java is my favorite language, but this is simply untrue. Many people are confused about how object references work, problems with null are very common, generic type erasure can be _very_ confusing, and there are plenty of other rough edges.
That said, Java does get a lot of undeserved hate. It's really quite a pleasant language and it has evolved a lot over the last 10 years.
I like it because it's comprehensively organized. I mean it does everything it should and does it a nice orderly way. The whole class system and c-like system, all together, works good. And addresses everything I want it to address.
And it does that without adding a bunch of crap. Or at least I find that crap easy to ignore. But ya, they do seem to be adding a lot of crap.
In a nutshell, it's a nice neat system. Python is a mess, comparatively.
I'm looking for a new language tho. Orderly and comprehensive like Java, but succincter.
> And it does that without adding a bunch of crap. Or at least I find that crap easy to ignore. But ya, they do seem to be adding a lot of crap.
Are you referring to the newer language features? I've personally really enjoyed what's been added and felt that it's unobtrusive, though I haven't been able to use an LTS newer than 17 yet.
Perhaps go? Famous for not having 'a bunch of crap'.
Alternativly python with it's typechecker in strict mode is quite organized. Though poorly typed outside dependencies are common. And ofcourse performance is much worse.
Go and Python are both fine languages, but they aren't great replacements to Java (for me).
I think Go will be a great language in 5-10 years. The authors of the language wanted to make something minimal, and they did a great job of that, but it was too barebones, though they are rectifying this by adding in critical features (like IMO, generics)
I like Python for small scripts, but I feel like I'm constantly fighting the language when managing dependencies, dealing with init scripts, or figuring out which of the 10 ways I should use to perform a common task.
I don't think there is a great replacement for Java. TypeScript with Bun or Deno might be the closest alternative right now, at least for me.
This is something I should look into. I've always felt that if I reach for Scala I might as well use a "real" functional language like Haskell, but maybe I'd find Scala to strike the right balance.
Personally the functional zealots are what put me off of Scala. I’m a firm believer that you need to use the appropriate style for your problem; oo, functional, or procedural. I also really like exceptions and don’t want to go in Result hell.
My problem with Java is that it is excessively object-oriented. I understand they've toned that aspect of it way down, but when I worked in Java between 2003-2009 it was gospel and it was preachy. But wow did I grow to hate it over time. I entirely quit programming for a couple of years, in fact. Then I discovered Python and I couldn't believe how easy it made everything, so I came back.
To be honest, that over-architecture everything stuff came from even earlier with C++, that many people forget.
Java was just a valid target to many programs that were previously written in C++, and some of that mindset stuck.
I would argue though, that today’s java is not excessively OOP, and can be written in an elegant hybrid of OOP and FP, whichever suits the given subproblem best. (It literally has algebraic datatypes and pattern matching now).
Not really, if you compare the STL with the java standard library, the STL is much more category-theory / traits-oriented, and has a lot of "free" functions, where the java standard library is OOP madness.
Gradle can become a mess when people want to show their smartness with the build system, don't allow them (even better if one just avoids Gradle entirely).
Maven works fine, it has so much history that most problems you encounter have been solved. It's also pluggable so you can extend to your heart's desires (but you shouldn't, same as Gradle when people try to be too smart with plugins it's time to tell them to stop).
Could it be better? Of course! But just as the rest of Java: it gets the job done, and it's absolutely fine after you deal with some idiosyncrasies. Every programming language environment has their own unique set of idiosyncrasies to deal with.
Java is a workhorse, it's not sexy, it's not pretty but will get you pretty far in most production environments.
This is annoyingly true, although maven is nice enough to work with. Certainly beats its alternatives handily, being declarative and such. Not nearly as annoying as Ant and Ivy, and at least IMO, the less one needs to think about Gradle, the better.
Maven and Gradle both have a learning curve, but IMO they're better than what exists for C/C++ (which really don't have _any_ standard dependency management).
I would like to add though that there is a big difference between a language-specific build tool that pretty much can’t build anything else and will bleed out at the first sight of another language in the project, and something like Gradle which is a fully generic build system capable of, say, a whole android build.
Of course a specialist will be “more elegant” at solving it’s intended problem, but that’s often not all we need.
I much prefer composing a handful of simple and elegant tools, rather than working with a single ones that tries to do everything for everyone. The latter invariably turns into a mess.
I don't mind Java so much as the types of code people tend to write with it. No one needs a FooReactorFactoryFactory when. There is only a single FooReactor which is always chosen. I've recently learned that it's possible to write such abominations in C++, which is equally bad.
And don't get me started on dependency injection systems like Guice. @Inject just means I have no way of knowing the type of an object or how it is initialized without sifting through the entire code base looking for an @Provides. And even then I can never be sure.
1) A lot of enterprise devs think all problems are best solved in Java, and refuse to acknowledge anything else (looking at the IBMers in the room)
2) Spring Boot takes what you don't like about Guice and cranks it up ten levels. It's so common in the industry that it might as well be adopted as a javax package now.
> @Inject just means I have no way of knowing the type of an object or how it is initialized without sifting through the entire code base looking for an @Provides.
If you're using interfaces correctly with strong contracts, the concrete implementation shouldn't matter.
This falls apart very quickly if your implementations don't match the contract.
Guice can be quite pleasant (though confusing at first) since it makes it easy to test your code and easily swap out implementations of classes, e.g. for dev vs prod.
Guice is honestly the worst DI container I’ve ever used. It has no life cycle support and you have to hack around all of its features. Id take any of the popular ones over it. Spring/Avaje/CDI. Most likely Avaje since it most easily supports compile time injection.
I just don't want to live in that world of colored functions and the amount of sync -> async changes I'll have to review from the bottom up every time IO gets stuffed into the middle of somewhere.
As far as I am aware, Java is the only major programming language (other than JavaScript, which is also the only worse major language) that was not organically adopted by programmers.
JavaScript was forced on the world by being the language of the browser, but Java was foisted on the world by Sun Microsystems in a massive marketing campaign.
And then Java was bought by the kind of person who isn't a programmer but needs to make some kind of sensible choice at some company, along with the kind of person who needs to teach freshman programming according to the latest fad.
Don't forget the millions Sun spent literally ADVERTISING Java.
For 2-3 years (2003-4?) every other tech-related book that was published had something to do with Java. I remember going into a Barnes and Noble once, back in that era, and walking down an aisle that felt like it was 30 feet long and four shelves high of just Java books. It was all marketing.
After a decade people suddenly woke up and realized "oh, Java sucks".
Then the smart kids moved on, but the rest of the world is now stuck with Java, and there will always be those kinds of people around who aren't programmers but who need to make what they think is some kind of sensible choice.
Of course, the Java community has also realized what a pile of ** Java was, so now they've added all sorts of lambdas and better syntax and whatnot, but it's band-aids on top of a fundamental misconception, which is that object-oriented programming is the best way to model software engineering problems.
> JavaScript was forced on the world by being the language of the browser, but Java was foisted on the world by Sun Microsystems in a massive marketing campaign. [...] Don't forget the millions Sun spent literally ADVERTISING Java.
Also, don't ever forget that Java was the other "language of the browser". Netscape came bundled with a Java Runtime Environment, back when everyone used Netscape. You were supposed to write your web application as Java applets, with JavaScript being the bridge between the static HTML world and the dynamic Java applet world (which also explains why its name was changed from LiveScript to JavaScript).
At least back in the day (might be fixed now) with type covariant mutable arrays.
That is array of cat is a subtype of array of animal. And a function Foo taking an array of animals and appending a dog is legal. Because array of cat is a subtype of array of animal, you can pass an array of cats to foo and append a dog. I believe this causes a runtime type exception, in a type-checked language without using any dangerous casts.
I kind of assume modern java has addressed this somehow, I would love to hear how.
No, this is still a thing (and a performance issue) in both Java and C#.
Covariance has a substantial penalty on array writes (20-40% depending on the benchmark).
I'm not that familiar with Java, but in C#, the only ways to avoid the penalty are either making the class of the array type sealed (so the runtime knows that you can't put any subtype into it) or using a construct like this if you work with someone else's type which you can't make sealed:
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static T access<T>(T[] arr, int index) {
ref T tableRef = ref MemoryMarshal.GetArrayDataReference(arr);
return Unsafe.Add(ref tableRef, index);
}
(this doesn't bounds check either, and hard-crashes on an empty array so you need to guard it appropriately)
This does not hard-crash on an empty array[0] but can still cause bad UB and eventually crash should it point past allocated memory page or to other random data (spec allows to have a byref that would point to the last element of array if it were one element longer, but such dereference out of bounds is still UB).
Covariance is unfortunate choice of arrays of T where T is class and is widely considered a mistake today. When you pass Memory<T> or Span<T>, there are no covariance checks involved as they disallow it.
It is also less of an issue in .NET in general because quite often T is a struct instead, which does not have covariance in the OOP meaning of the word (old-style int[] to uint[] casts are just reinterprets, they are frowned upon luckily and few codebases use them).
"Absolutely no reason" is wrong, "almost certainly no reason for most programs" is more accurate. You'll need arrays of you're writing buffers, various types of collections (B-trees, hashtables), etc.
Well, while I do agree with you (I would even add performance-sensitive code to your list. Something like int[] applied at the correct places can do a lot), if we want to be absolutely nitpicky one can just use ByteBuffers over arrays for pretty much everything.
A comment from an alternative reality where code patterns, crazy code generation frameworks, and crazy systems entirely based on dependency injection do not exist...
Oracle had employees called Java Evangelists for a while but I think that was more than a decade ago. You never know when somebody gets their stereotypes though.
My memory is hazy but I think there may have been more of a cult of Java when Sun was in charge of things. They knew now to inspire a following.
> I think there may have been more of a cult of Java when Sun was in charge of things.
I wouldn't call it a "cult", just normal technology hype. Yeah, it was there in the late 90s, but since at least 2010s, Java is a boring enterprise platform without much hype.
It might have before, like, 2005 when it became the default “intro to programming” language. And it might get some now that Python has taken that job. But for a while it was the “minimum requirements: fulfilled” of programming languages. There’s a lot to be said for fulfilling minimum requirements but that doesn’t tend to inspire evangelism.
It is a perfectly fine language in as much as I saw, that must be why it was selected for the sad fate of being many overwhelmed students’ first language.
Yeah, since the ‘90s, and still does in this thread. They’re usually going around insisting you ignore that your lying eyes can consistently spot Java programs by their incredible memory bloat and poor performance, and that actually it has great performance (in some synthetic benchmarks).
I do think they’ve gained a little credibility now that we’ve inexplicably decided Electron is a serious platform and not an April Fool’s joke.
Garbage collectors can be scary, but I wouldn't want to live without them! I have observed what feels like an irrational fear of GC in different programming communities. Those communities tend to invent something else that they don't call GC but is either complicated in its own way or not nearly as robust as a proper GC.
I am personally very excited about a new state-of-the-art GC with no dependencies that is easy to embed called Whippet. The project has received grant funding through NLnet to finish implementation: https://nlnet.nl/project/Whippet/
A couple months ago I finished translating a simple generational garbage collector from MIPS[1] to x86-64 assembly[2], so that programms emitted by my toy compiler[3] run on the actual hardware instead of a MIPS emulator.
And sure enough, I had introduced a few bugs along the way. Here is a part of just one commit message:
> NoLeak-InsertionSort.cool exposed a problem where we make a copy of a prototype object
to pass it as `this` to the constructor. But while evaluating all the other constructor
parameters the copy gets promoted to Old Area. As a result any assignments the constructor
makes end up recorded in the assignment stack. So that the referenced objects survive the
next minor collection whereas they shouldn't have otherwise.
"Scary" is actually something of an understatement. They weren't kidding when they said it was one of the most shark infested areas of programming.
My lisp's garbage collector works by scanning the stack for pointers. I neglected to spill the registers onto the stack and it led to such nonsensical problems that I'm not even sure how I debugged it. I think my mind has blocked out the experience as a bad memory. I've gotten into the habit of hacking in a call into the garbage collector directly into the eval and cons of debug builds just to stress the garbage collector as much as possible and try to find corruptions near their sources.
Not spilling registers into the stack will show up quickly.
You have to instigate a GC torture test as part of your testing, combined with a way of detecting use-after-free. (An obvious way is to reset the type of freed objects, so any use of such an object runs into type mismatches.)
A GC torture tests causes the collection routine to be called often, like after every allocation. If an object is allocated, and now held only in an unspilled register, and then another object is allocated; that first object will be wrongly freed. This is what we want: reproduce the problem, and then hopefully detect it.
If you're garbage collecting underneath a compiler that doesn't know about garbage collection, you have to worry about not only values held in registers not being spilled, but values that are optimized away entirely, which your garbage collector has to see.
The problem is that param has no next use after it is assigned to bar_attribute; as far as the optimizer is concerned, it is now a dead variable. Whatever register it occupied can be reused for something else.
Let's assume our garbage collector is conservative only w.r.t. stack scanning. Heap objects are traced precisely. We are not scanning every malloc object in existence, in other words, and so, the bar_attribute is not visible to the garbage collector.
Once we call create_heap_object(foo_type, foo), the foo_object structure becomes hooked up to a garbage-collected heap object, and then it is visible to the garbage collector. The foo_type has garbage collector hooks for traversing the "struct foo_object".
Problem is, create_heap_object calls into the garbage collection module to allocate a new heap object, and that can trigger GC. When GC runs, it can wrongly collect the param object.
This can happen if the function has a caller which itself has let go of the param object, like:
Again, tparam has no next use, and so it can cease to exist in this stack frame the moment that foo_create is called. I.e. the only reference to tparam in the entire program is the activation frame of foo_create. Scanning the stack of caller will not find it.
I assumed that param would still be reachable because foo_object is reachable. If this is not the case, is it because struct foo_object differs from the obj type? Perhaps that causes the garbage collector to fail to find the pointers.
Will the code be correct if I ensure that every value on the stack can be recognized by the garbage collector?
If not, then I suppose the way to avoid those issues is to tell the compiler the variable is used somehow. There's the volatile keyword and also lots of compiler attributes I can use. I'm finding it pretty difficult to determine which variables need to be marked this way though.
What is the proper discipline? How did you solve the problem?
I compile with strict aliasing disabled because of problems like these... Maybe I need to disable more optimizations.
For anyone interested in GC internals for a language that mixes low-level and high-level primitives*, I can strongly recommend watching Konrad Kokosa lectures on .NET's GC internals.
They are very accessible and only assume you have light C++ background.
People recommend tracing garbage collection because it performs better than atomic reference counting, even though reference counting is much simpler.
Now, suppose I were designing a language where data is always owned by at most one thread. I can skip making the ref counter an atomic variable, and with some help of the language do reference counting at compile time: would I still benefit from the complexity of a tracing GC if my goal is performance and predictable latency?
I want to have automatic memory management for both kernel and apps. Creating a concurrent tracing GC that walks the entire user and kernel space sounds incredibly more complicated than just inc/dec'ing a counter and freeing if it reaches zero (again, assuming only one thread can access a single object)
I know that I would have to add language support for weak pointers or forbid cyclic references altogether, but I wonder if the dislike for reference counting only exists because it doesn't work well for data that's shared across threads.
EDIT: thanks all, you have brought excellent arguments and you have convinced me. In fact, in this actor-based OS I am working on, GC+arena based allocation per actor might be the most performing approach. GC collection can happen in the scheduler between messages (so becomes virtually pauseless), and when an actor terminates, just reclaim the entire arena.
Yes you do still benefit, otherwise V8 would use refcounting for everything.
Problems with refcounting:
1. You leak cycles. Given the point of a memory management scheme is to reliably not leak, this is a major weakness. Cycles are very common and natural things to find in heaps, and even if you avoid them they can easily be introduced years later by people you will never meet who weren't aware of the entire heap topology. You say you'd just "forbid cyclic references": how will you do that? Require everything to be written in Rust?
2. Every object gets bigger, as it must store a reference. This increases memory usage and reduces locality. With a GC it's possible to squeeze the overhead down to a vtable pointer (required anyway in most cases) and then a few bits on top!
3. All the incs/decs bloat your code, creating a form of pervasive slowness that doesn't show up in profilers except in hot loops. This tempts you to micro-optimize add/decrefs, but that increases the chance of mistakes.
4. Requires a very consistent protocol for how and when references are taken and released. The details here can get complicated. For example do containers add/decref for you, or do they expect you to do that?
5. You are exposed to use-after-free bugs, which are a major source of security holes.
There are probably more. And obviously the restriction to not use threads is catastrophic for any system that cares about performance.
The problem with having the ability to hold managed pointers from kernel<->userspace (whether using rc or gc) is that a process might be malicious or arbitrarily buggy, in the standard OS model. It means you have to be able to kill the process and release all its resources, which means the kernel must know where all the references to kernel-side objects are coming from so they can be removed on kill.
To allow object graphs to span the user/kernel space boundary therefore requires the kernel to mandate the use of a specific compiler that's a part of the TCB (i.e. a part of the kernel itself). That radically changes the design of the whole OS. In particular you cannot implement a UNIX that way.
As sibling comment mentioned, reference counting is very much non-predictable in latency.
If you want predictable, you want your GC to run either in constant time slice, or an upper-bounded timeslice.
Deferred refcounts can become quite complex if you want to avoid "I have just released last reference to something that contained a lot of other referenced" (there are some funny threads where Rust users discover that they sometimes need to spin an extra thread to not have effective "stop the world" XD).
So instead common approach is tracing GC with some form timesliced running collector, guaranteeing that time taken from mutator will be upper-bounded. For example, instead of stopping the world, concurrent GC will usually only require a synchronization point. Sometimes it also means that the collector will be given explicit timeslice to ensure that there's certain amount of work being done - it's all tradeoffs based on how much throughput and latency you want.
You can speed up tracing collectors through things like escape analysis and 1bit reference counting (for example, you set a bit if a new reference is passed outside of current scope, every object without bit set can be assumed to have all referenced contained in given scope, etc).
> People recommend tracing garbage collection because it performs better than atomic reference counting, even though reference counting is much simpler.
Reference counting is a lot more complicated! It turns every read into a write.
> [...] if my goal is performance and predictable latency?
Reference counters don't have predictable latency: a single reference decrement can lead to an arbitrary amount of follow up work, like more decrements and deallocations.
Performance of reference counting isn't generally better than that of garbage collectors. (Especially since you still need a backup solution to deal with cycles.)
Figuring out a lot of your memory management at compile time is definitely a win. But doesn't really have much to do with reference counting.
> Reference counting is a lot more complicated! It turns every read into a write.
Why? Maybe it's crucial to the story, but I am exploring Rust-like single ownership semantics.
You would have to "write" only when you create a new reference to something, or the reference is destroyed. But it wouldn't turn any read into a write, no?
Rust ownership works because it runs entirely at compile time. Rust has infrastructure for runtime inference counting, but you need to actively use it, and it's complicated.
Also, every time you read a pointer you are creating a new reference. That's what turns "every read into a write". You are correct that it's not actually every read, it's just that every small block that reads the value will also write over it.
You don’t do this in Rust. Up u can take a normal &T into something held by an Rc<T>. The count will not be touched.
It’s only when you need an additional owning reference that you call clone() and increment the count, or when one of the owners goes out of scope, and Drop decrements the count.
But for non-owning cases, which are many of them in most code, the count stays the same.
It’s part of the standard library, not the language, so it’s not really a cost to the compiler.
And this just uses all the same rules as normal Rust code, so like, sure, to use features you have to know them, but I find it a bit silly to frame that as a “cost.” If that’s a cost, so is literally everything.
Anyway my main point is just that you have less refount traffic than in other implementations.
If were going down the calling silly route, I'd rather categorize handwaving of Rusts additional complexities by their community as silly.
If "everything has a cost" anyway we might as well code is assembly for optimal performance. But no, because nuances and differences of cognitive load during development can be significant. And so does compilation speed.
It's arguably much easier to code with a GC and language adoption reflects that.
> I'd rather categorize handwaving of Rusts additional complexities by their community as silly.
I believe what steveklabnik meant, was that all this extra complexity (the borrow checker) already exists in the language for other reasons. It's not an additional cost to the compiler or the developer when using the reference-counted types (Rc or Arc).
Not exactly; it turns every passing of a reference, even when it's going to be used only for reading (or not used at all, or just passed on further) into a write of the reference count. But if you can read without passing the reference to somewhere else (or if the compiler can prove that the reference the caller has outlives the reference it would have passed), it can omit the write.
And tracing garbage collection is not completely immune to this; some kinds also turn every read into a write (into a card marking table), copying gc periodically rewrites unmodified objects (when moving them during garbage collection), and the tracing itself might need to read cold objects which might even be located on disk (swapped out).
> Reference counters don't have predictable latency: a single reference decrement can lead to an arbitrary amount of follow up work, like more decrements and deallocations.
However, they have a predictable place for that latency: it can only happen when there's a reference decrement (and the amount of extra work at that point is bounded by the size of the graph which can be reached from that reference). It also will not affect other unrelated threads. With a stop-the-world GC, a thread which allocates too much can cause pauses in threads which are not even allocating or releasing memory at that moment.
> Performance of reference counting isn't generally better than that of garbage collectors. (Especially since you still need a backup solution to deal with cycles.)
However, the memory use of reference counting is generally better than that of tracing garbage collectors, since it releases memory as soon as it can be released. That is, tracing garbage collectors trade extra memory use for their speed; giving them more memory (up to a point) means they can do less work, and giving them too little extra memory can make them much slower.
> Figuring out a lot of your memory management at compile time is definitely a win. But doesn't really have much to do with reference counting.
It does, because it can help reduce the cost of reference counting (by eliding most of the reference count manipulation when it's not necessary).
Yes, if you compare an implementation of reference counting that doesn't stop the world with an implementation of GC that does explicitly stop the world, you will find that GC stops the world more.
> Reference counters don't have predictable latency: a single reference decrement can lead to an arbitrary amount of follow up work, like more decrements and deallocations.
You can push the cleanup work on a queue to do it asynchronously, to bound the amount of work per deref / to even the work out.
The dislike started earlier than the rise of pervasive threading. As another poster said, every read turns into a write, meaning, everytime you pass a reference into a function you have to increment the counter, and decrement when the function exits. It turns out, the vast majority of of inc/dec ops are pointless ones like this, which is considerable overhead for literally no benefit.
You can elide some of them using a sort of borrow analysis. But borrowed refs can only be used in certain ways, and so this analysis isn't perfect and can't eliminate all of the unnecessary overhead. This was all hashed out in the 80s and 90s IIRC, before multithreading even became a big issue.
The complex analyses needed to make RC perform well end up destroying the simplicity argument, and even after they still aren't competitive with tracing.
Rust has this. Data can be placed in atomic or non-atomic refcounted containers, and the type system disallows sharing non-thread-safe types across threads.
Additionally, data can be borrowed from inside the refcounted wrapper, and used freely within a scope without touching the reference count.
You can still have multithreading, you just have to forbid shared data. You can move data between threads at thread creation and termination, and for everything else the language can provide channels/queues to move data between threads. Erlang operates under that model and is great at multithreading
CSP often locks you into a single consumer style of pattern, which is highly problematic and scales poorly for anything that isn't statically partitionable. Once you go to MPMC, you start sharing data which immediately forces you to have proper ARC with the high synchronization cost it entails (even without using atomics, RC forces cacheline eviction between cores meaning it acts as a brutal bottleneck on many-core systems per Amadhal's law).
That is indeed the goal I am working towards, but a bit more relaxed than Erlang: mutability is possible only within a single actor/thread, but anything sent in a message to another actor is deep copied. So no memory is ever shared, but the language allows single-threaded mutation for performance.
Nice to see someone exploring from first principals and implementing interesting things. And referencing past work! Gives me hope actually. One nit; I disagree with “scary”, I think “interesting” and even “fun”!
I suppose it depends on precisely what you mean by "good". There are some very decent concurrent collectors that are very simple. The Chicken collector is fairly straightforward and real-time to boot, and seems to have only slightly lower throughput than concurrent MS:
Depends how you define "good". I consider this concurrent GC algorithm [1] to be "good" and it can be implemented in a few hundred lines of C. I'm going to disagree with the article and say GC's aren't "scary" but they do require thorough testing.
I mean sure, a post on the topic of correctness bugs in GCs is going to bring out some examples of correctness bugs in GCs. But I haven't encountered any independently.
For some reason I read this as "garbage disposals are scary" and then I got a flashback to reading a Stephen King novel as a child (iirc it was Firestarter) where a character commits suicide by sticking their arm into their kitchen garbage disposal, it horrified me at the time and appears to have remained deeply embedded in my personal neural network...
Finally got enough information and realized that the padding of a specific object was wrong (GC expected 16 bytes, object was 12 bytes). This caused dozens of other corruption bugs to disappear that we didn't even think were GC related.