Ruby Beyond CRuby: Why JRuby and TruffleRuby Exist? What Ruby 4.0 Really Changed

In my previous posts, I looked at how Ruby code eventually reaches the VM, native runtime and CPU.

That naturally leads to another question:

Why is there more than one Ruby?

Most Ruby developers use CRuby/MRI and may never think about it. But Ruby is a language specification, not a single runtime implementation.

Today we have several implementations, with two particularly interesting alternatives:

JRuby – Ruby implemented on the JVM.

TruffleRuby – Ruby implemented using the GraalVM/Truffle ecosystem.

And then there is the increasingly interesting question:

Did Ruby 4 finally remove the GIL and solve Ruby’s performance/scalability problems?

Not exactly.

Let’s look at why these implementations exist and where Ruby 4.0 stands today.


Ruby is a language, not necessarily an implementation

When I write:

class User
  def greet
    "Hello"
  end
end

I’m writing Ruby language semantics.

But somebody has to implement those semantics.

There is no requirement that the implementation must be written in C.

So we can have:

                    Ruby Language
                         │
          ┌──────────────┼──────────────┐
          ▼              ▼              ▼
       CRuby           JRuby       TruffleRuby
          │              │              │
          ▼              ▼              ▼
       C / VM           JVM       GraalVM / Truffle

All three attempt to behave like Ruby while using very different execution technologies.


Why was JRuby created?

JRuby’s fundamental idea was:

What if Ruby could run on the JVM and take advantage of everything the JVM already provides?

The JVM already has:

  • mature garbage collection
  • JIT compilation
  • highly optimized threading
  • profiling
  • excellent runtime tooling
  • enormous Java libraries
  • mature production infrastructure

Instead of building all of that from scratch, JRuby puts a Ruby implementation on top of the JVM.

Ruby code
    │
    ▼
JRuby
    │
    ▼
JVM
    │
    ├── JIT
    ├── GC
    ├── Threads
    └── Java libraries
    │
    ▼
CPU

JRuby explicitly aims to provide Ruby without a global interpreter lock, true parallelism and integration with Java. (GitHub)

That makes JRuby particularly interesting for applications where Ruby needs to coexist with Java infrastructure.


JRuby’s biggest advantage: true parallel Ruby threads

In CRuby, ordinary Ruby threads are native threads, but Ruby execution within a single Ractor is constrained by its GVL.

JRuby takes a different approach.

Multiple Ruby threads can execute Ruby code concurrently because there is no equivalent global interpreter lock preventing Ruby threads from running in parallel.

Conceptually:

CRuby

Thread 1 ──┐
Thread 2 ──┼──→ GVL ──→ Ruby execution
Thread 3 ──┘

Whereas:

JRuby

Thread 1 ─────────────→ CPU Core 1
Thread 2 ─────────────→ CPU Core 2
Thread 3 ─────────────→ CPU Core 3

That can be extremely valuable for CPU-heavy or highly concurrent workloads.

JRuby 10 also moved to Java 21 and made invokedynamic optimization the default, taking advantage of more modern JVM capabilities. (blog.jruby.org)


Why TruffleRuby?

TruffleRuby comes from a completely different idea.

Instead of saying:

“Let’s implement Ruby using the JVM.”

the Truffle approach essentially says:

“Let’s implement Ruby on a framework designed to build highly optimizing language runtimes.”

TruffleRuby uses the Truffle framework and GraalVM.

Ruby source
     │
     ▼
TruffleRuby
     │
     ▼
Truffle AST / runtime
     │
     ▼
Graal compiler
     │
     ▼
Optimized machine code
     │
     ▼
CPU

The interesting part is that Truffle/Graal can observe running code and aggressively specialize and optimize it.

TruffleRuby’s project explicitly targets high performance for Ruby workloads, parallel execution without a global interpreter lock, native extensions, and interoperability with Java and other languages in the GraalVM ecosystem. (GitHub)


TruffleRuby and JRuby solve a similar problem differently

This distinction is important.

CRubyJRubyTruffleRuby
Main technologyC + Ruby VMJVMTruffle + GraalVM
GVL for normal Ruby threadsYesNoNo
Parallel Ruby threadsLimited by GVLYesYes
JVM ecosystemNoExcellentExcellent
JITYJIT/ZJITJVM JITGraal
Native extensionsExcellentDifferent approachMany C extensions supported
StartupExcellentGenerally slowerDepends on configuration
Warm-upLowHigherHigher
Peak performanceVery goodVery goodExcellent for suitable workloads

The important lesson is:

There isn’t one universally “best Ruby”.

The optimal runtime depends on the workload.


Now the big question: Does Ruby 4 remove the GIL?

No.

And there is an important terminology correction.

CRuby generally calls it the GVL – Global VM Lock.

Ruby 4.0 did not remove it from normal Ruby threads.

Ruby’s documentation states that threads within the same Ractor share a ractor-wide GVL and therefore cannot execute Ruby code in parallel with each other. Threads belonging to different Ractors can execute in parallel. (Ruby Documentation)

So:

                    CRuby 4.0
                       │
          ┌────────────┴────────────┐
          ▼                         ▼
      Ractor A                  Ractor B
          │                         │
     Thread 1                   Thread 1
     Thread 2                   Thread 2
          │                         │
        one GVL                  one GVL
          │                         │
          └──────────┬──────────────┘
                     │
               parallel execution

This is a major distinction.

Ruby 4 did not say:

“GVL is gone.”

It moved Ruby’s concurrency model further toward Ractor-based parallelism.


Ruby 4.0 significantly improved Ractors

Ruby 4.0 invested heavily in reducing the contention that previously limited Ractor scalability.

The release notes specifically mention improvements such as:

  • lock-free structures for frozen strings and the symbol table
  • fewer locks in method-cache lookups
  • faster instance-variable access
  • reduced allocation contention
  • reduced CPU cache contention
  • fewer locks around object_id
  • fixes for deadlocks and GC races involving Ractors (Ruby)

This is a much deeper improvement than simply deleting one lock.

The architecture is moving toward:

Before

Ractor ──┐
Ractor ──┼── shared internal state ── contention
Ractor ──┘


Ruby 4 direction

Ractor A ── mostly independent state
Ractor B ── mostly independent state
Ractor C ── mostly independent state

             ↓

       less lock contention
       less cache contention
       better parallelism

Ruby 4 also introduced Ractor::Port as a new synchronization mechanism and added shareable Proc/lambda APIs.


Ruby 4’s bigger performance story: YJIT and ZJIT

Ruby 4.0 introduced ZJIT, the next-generation JIT compiler after YJIT.

The interesting part is that Ruby now has two very different JIT stories:

                 Ruby 4
                   │
          ┌────────┴────────┐
          ▼                 ▼
        YJIT               ZJIT
      mature              new
      production          experimental
          │                 │
          ▼                 ▼
    native machine code   native code

Ruby’s own release announcement is very clear:

ZJIT is faster than the interpreter, but not yet as fast as YJIT.

Ruby 4.0 therefore recommends experimentation rather than production deployment for ZJIT.

ZJIT is intended to raise Ruby’s performance ceiling through larger compilation units and SSA-based intermediate representation, while also making the compiler architecture more approachable for outside contributors.

So Ruby 4 did not replace YJIT with a magically faster JIT overnight.

It started building the next generation.


Ruby 4 also improved the GC and object system

Some of the most interesting Ruby 4 changes aren’t visible from Ruby syntax at all.

Ruby 4.0 includes improvements such as:

• Independent growth of GC heaps for different size pools
• Faster sweeping of pages containing large objects
• Faster Class#new
• Improved instance-variable storage
• Less GC overhead from write barriers
• Better handling of embedded large Bignums
• Faster object_id/hash operations

These changes target allocation, memory consumption, GC work, object access, and general runtime overhead. (Ruby)

For a Rails application, these details matter because a significant amount of application work eventually becomes:

allocate
object lives
object becomes unreachable
GC
CPU + memory bandwidth

Improving that pipeline can produce real application-level benefits without changing your Rails code.


Ruby 4’s interesting new feature: Ruby Box

Ruby 4.0 also introduced an experimental feature called Ruby Box.

It allows definitions and changes to be isolated from other boxes.

That includes things like:

  • monkey patches
  • class/module definitions
  • class/global variables
  • loaded libraries

One proposed use case is running multiple isolated application versions in the same Ruby process—for example, blue/green deployment scenarios.

Conceptually:

Ruby Process
├── Box A → Application version A
├── Box B → Application version B
└── Box C → Experiment

This is quite different from the normal Ruby process model and could become more interesting over time.


So did Ruby 4 “fix Ruby performance”?

No single release can be described that way.

Ruby’s performance problem has never been just one problem.

There are several:

Ruby performance
├── Interpreter overhead
├── Method dispatch
├── Object allocation
├── Garbage collection
├── Memory/cache behaviour
├── JIT compilation
├── Lock contention
└── Parallel execution

Ruby 4 improves several of these.

But each improvement has trade-offs.


Ruby 4: the best features

For an experienced Ruby/Rails developer, I would highlight these:

1. Better parallelism

Ractors are substantially more mature and have significantly less internal contention. (Ruby)

2. Better JIT direction

YJIT remains the mature choice, while ZJIT establishes a new JIT architecture with a higher long-term performance goal.

3. Runtime and GC improvements

Allocation, sweeping, object access, and GC overhead have all received attention.

4. Ruby Box

A fascinating new isolation primitive that may eventually influence how long-running Ruby processes host multiple isolated applications.

5. Ecosystem maturity

Ruby 4 continues to preserve the programming model that makes Rails productive while the runtime underneath becomes increasingly sophisticated.


But Ruby 4 still has limitations

The biggest one is straightforward:

Normal Ruby threads still don’t provide unrestricted CPU parallelism inside one Ractor.

The GVL remains part of CRuby’s threading model. (Ruby Documentation)

There are also practical considerations around Ractors: code must respect Ractor isolation and shareability rules, and not every gem or application architecture will naturally benefit from them.

And ZJIT is not yet a drop-in reason to turn off YJIT and deploy it everywhere; Ruby 4.0’s own release notes explicitly say it is not yet as fast as YJIT and recommend holding off on production use.


What about Ruby 4 vs JRuby and TruffleRuby?

This is where Ruby becomes particularly interesting.

                       Ruby
                        │
       ┌────────────────┼────────────────┐
       │                │                │
       ▼                ▼                ▼
     CRuby             JRuby        TruffleRuby
       │                │                │
       ▼                ▼                ▼
     C/VM              JVM        Graal/Truffle
       │                │                │
       ▼                ▼                ▼
     YJIT             JVM JIT        Graal JIT
       │                │                │
       ▼                ▼                ▼
    Ractors         real threads    real threads

CRuby’s advantage is its enormous compatibility, mature ecosystem, excellent startup characteristics, and continued optimization of the standard implementation.

JRuby’s strength is the JVM: parallel Ruby threads and access to the Java ecosystem.

TruffleRuby’s strength is aggressive specialization and Graal-based optimization, with parallel Ruby execution and polyglot capabilities. Its maintainers report very high performance on appropriate benchmark workloads, though warm-up and compatibility remain practical considerations.


My conclusion as a Ruby developer

I think the most important change is not:

“Ruby 4 removed the GIL.”

It didn’t.

The more accurate statement is:

Ruby is steadily evolving from a primarily interpreter-centric runtime toward a highly optimized, JIT-driven, increasingly parallel execution platform.

The interesting evolution looks like this:

Old Ruby
   │
   ▼
Interpreter
   │
   ▼
GVL
   │
   ▼
Threads mostly for concurrency


Modern Ruby
   │
   ├── YJIT
   ├── ZJIT
   ├── better GC
   ├── better object representation
   ├── reduced lock contention
   └── Ractors
           │
           ▼
      parallel Ruby

And this is exactly why learning C and runtime internals is becoming more valuable.

When you understand memory, object allocation, GC, locks, CPU caches, JITs, threads, and process boundaries, Ruby 4’s changes stop looking like a collection of release notes.

You start seeing the bigger picture:

The Ruby language hasn’t changed its philosophy of developer productivity. The runtime underneath it is becoming increasingly sophisticated at extracting performance from that high-level language.

As of August 2026, the current stable Ruby 4 branch is Ruby 4.0, with Ruby 4.0.6 released on July 14, 2026. (Ruby)

And that makes this a perfect point in the series to go one level deeper:

What actually happens inside a Ractor, how its GVL differs from the old “global” model and how Ruby can execute Ruby code in parallel without simply removing thread safety?

Happy Rubying!

What Really Happens When Ruby Code Executes?

As Ruby developers, we normally think execution is simple:

ruby app.rb

Ruby runs the file.

But what exactly is ruby?

Does the CPU execute Ruby code directly?

What is the Ruby interpreter?

Where does bytecode come into the picture?

What exactly is the runtime?

And where do C, machine code and the operating system enter the story?

For a developer who wants to understand Ruby beyond the language syntax, these are important questions.

This article follows a small Ruby program from source code all the way down to CPU execution.

Note: The discussion here focuses on CRuby/MRI- the standard Ruby implementation. Details differ in JRuby, TruffleRuby and other implementations. Ruby’s RubyVM APIs are explicitly MRI-specific. (docs.ruby-lang.org)


1. Start with a simple Ruby class

Consider this file:

# person.rb

class Person
  def initialize(name)
    @name = name
  end

  def greet
    "Hello, #{@name}"
  end
end

person = Person.new("Ruby")
puts person.greet

We execute it:

ruby person.rb

So what happens after we press Enter?


2. ruby is an executable program

When we type:

ruby person.rb

the shell does not understand Ruby syntax.

It finds the ruby executable in your PATH.

For example:

which ruby

might return:

/usr/bin/ruby

or perhaps a version-manager path such as:

/Users/me/.rbenv/shims/ruby

That executable is a compiled native program.

This is a crucial distinction:

Ruby source code is not itself executed by the operating system. The operating system starts the Ruby executable, and that program executes your Ruby program.

The flow initially looks like this:

Terminal
   │
   │ ruby person.rb
   ▼
Shell
   │
   │ locate executable
   ▼
Ruby executable
   │
   ▼
Operating System creates process

The ruby process is now running.


3. The Ruby interpreter is inside that process

People often say:

“Ruby interprets my code.”

This is useful shorthand, but the reality is more interesting.

The Ruby executable contains the runtime machinery necessary to:

  • read Ruby source
  • parse it
  • compile it
  • create internal structures
  • execute VM instructions
  • manage Ruby objects
  • run garbage collection
  • perform method calls
  • interact with the operating system

So we can think of:

ruby executable
       │
       ├── parser
       ├── compiler
       ├── VM
       ├── garbage collector
       ├── object system
       └── runtime libraries

This collection of mechanisms is what we generally mean by the Ruby runtime.


4. Source code is first parsed

Our source:

person = Person.new("Ruby")

is not immediately converted into CPU instructions.

Ruby first needs to understand its structure.

The parser turns the source into an internal representation of the program.

Conceptually:

Ruby source
    │
    ▼
Tokenizer / Parser
    │
    ▼
Internal syntax representation

For example, Ruby has to understand:

Person.new("Ruby")

as roughly:

receiver: Person
method:    new
argument:  "Ruby"

The exact internal representation is an implementation detail, but the important point is:

Ruby must understand the program before it can execute it.


5. Ruby then compiles the code into VM instructions

This is the part many Ruby developers don’t realize.

CRuby does not normally execute the original Ruby source line-by-line.

The code is compiled into instructions for Ruby’s virtual machine.

These are commonly referred to as YARV instructions or Ruby bytecode.

Ruby exposes this machinery through:

RubyVM::InstructionSequence

For example:

puts RubyVM::InstructionSequence.compile(
  'puts "Hello"'
).disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,12)>
0000 putself                                                          (   1)[Li]
0001 putchilledstring                       "Hello"
0003 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>
0005 leave
=> nil

You will see VM instructions rather than Ruby source.

The exact output changes between Ruby versions because the instruction set and compiler details are implementation-specific. Ruby documents InstructionSequence specifically as a way to inspect the VM’s compiled instructions.

So our pipeline becomes:

person.rb
   │
   ▼
Parser
   │
   ▼
Ruby internal representation
   │
   ▼
Compiler
   │
   ▼
YARV bytecode / InstructionSequence

6. What is bytecode?

Bytecode is an intermediate instruction format designed for a virtual machine.

It is not CPU machine code.

Think of this distinction:

Ruby source
    ↓
Ruby VM bytecode
    ↓
CPU machine code

Bytecode might conceptually contain operations such as:

putself
putobject
send
setlocal
getinstancevariable
leave

These aren’t x86 instructions.

They are instructions understood by the Ruby VM.

Ruby’s documentation exposes the compiled instruction sequence and its bytecode specifically for inspecting how YARV works. (docs.ruby-lang.org)


7. Enter the virtual machine

Now we have something like:

Ruby source
     ↓
Compiler
     ↓
YARV bytecode
     ↓
Ruby VM

The VM executes those instructions.

You can think of it as a machine built inside the Ruby process:

             Ruby Process
┌──────────────────────────────────────┐
│                                      │
│   Ruby VM                            │
│                                      │
│   ┌──────────────────────────────┐   │
│   │ YARV instructions             │   │
│   │                              │   │
│   │ putobject                    │   │
│   │ send                         │   │
│   │ getinstancevariable          │   │
│   │ leave                        │   │
│   └──────────────┬───────────────┘   │
│                  │                   │
│                  ▼                   │
│             VM execution             │
│                                      │
└──────────────────────────────────────┘

CRuby’s interpreter loop and instruction definitions are implemented in the Ruby source tree; the Ruby documentation points to insns.def and vm_exec.c as core pieces of this machinery. (docs.ruby-lang.org)

https://github.com/ruby/ruby/blob/master/vm_exec.c


8. But the VM itself is native code

Here is the important connection to C.

The Ruby VM isn’t written in Ruby.

CRuby itself is implemented primarily in C, with some components implemented in other languages.

So conceptually:

Your Ruby code
      ↓
Ruby bytecode
      ↓
CRuby VM
      ↓
C code
      ↓
Machine instructions
      ↓
CPU

This is where learning C becomes incredibly useful for a Ruby developer.

Ruby is high-level.

The Ruby runtime is much closer to the machine.


9. What happens with our Person class?

Take:

class Person
  def greet
    "Hello, #{@name}"
  end
end

Ruby compiles the class and its methods into VM instruction sequences.

There isn’t simply one giant sequence representing the entire application.

Different constructs can have different instruction sequences.

Ruby’s InstructionSequence#type can identify sequences such as:

:class
:method
:block
:rescue
:ensure
:top

among others. (docs.ruby-lang.org)

Conceptually:

Person class
     │
     ├── class instruction sequence
     │
     ├── initialize method sequence
     │
     └── greet method sequence

When:

person.greet

executes, the VM needs to resolve the method call and execute the corresponding instruction sequence.


10. Method calls become VM work

This Ruby:

person.greet

looks tiny.

Internally, Ruby has to determine:

1. What object is `person`?
2. What class does it belong to?
3. Which method is `greet`?
4. Is the method overridden?
5. What arguments are involved?
6. What execution frame should be created?
7. Which instructions should run?

Conceptually:

person.greet
     │
     ▼
VM method dispatch
     │
     ▼
Find `greet`
     │
     ▼
Create/enter execution frame
     │
     ▼
Execute method instructions

The exact internals are sophisticated, including method caches and object-shape optimizations, but the important thing is that the VM – not your operating system- understands the Ruby method call.


11. Where does the operating system come in?

Eventually, everything has to reach the real machine.

The operating system created the Ruby process.

It provides things such as:

virtual memory
threads
file descriptors
sockets
timers
process scheduling
system calls

When Ruby needs to write:

puts "Hello"

the operation eventually crosses from Ruby runtime code into OS facilities for output.

Conceptually:

puts
 ↓
Ruby implementation
 ↓
C runtime / OS interface
 ↓
system call
 ↓
Operating System
 ↓
terminal / file / pipe

The exact path can vary by platform and implementation, but this is the important architectural boundary.


12. Where does the CPU actually execute instructions?

Here is the complete picture:

┌──────────────────────────────┐
│       Ruby Source            │
│                              │
│  person.greet                │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ Parser / Compiler             │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ YARV Bytecode                │
│ Ruby VM instructions         │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ CRuby VM                     │
│ Native runtime implementation│
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ Native Machine Instructions  │
│ x86-64 / ARM64 / etc.        │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│ CPU                          │
└──────────────────────────────┘

That is the mental model I want to keep as a Ruby developer.


13. And then there is JIT

The previous diagram describes the interpreter path well, but modern Ruby can go further.

CRuby includes YJIT, a Just-In-Time compiler.

Instead of always executing VM bytecode through the interpreter, frequently executed code can be compiled into native machine code.

Conceptually:

             Ruby source
                  ↓
             VM bytecode
                  ↓
          ┌───────┴────────┐
          │                │
          ▼                ▼
     Interpreter         YJIT
          │                │
          ▼                ▼
      VM execution     Native code
          │                │
          └───────┬────────┘
                  ▼
                 CPU

YJIT became production-ready in Ruby 3.2, and Ruby’s documentation describes the interpreter and YJIT as different execution paths around the VM. (Ruby)

This is an important distinction:

Ruby bytecode is not necessarily the final form of execution.

Depending on how Ruby is running and whether JIT is enabled, execution can involve interpreted VM instructions, JIT-generated native code, or transitions between them.


14. Try it yourself

Check your Ruby implementation:

ruby -v

Check where the executable comes from:

which ruby

Inspect VM instructions:

ruby -e 'p RubyVM::InstructionSequence.compile("1 + 2").disasm'
"== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,5)>
0000 putobject_INT2FIX_1_ ( 1)[Li]
0001 putobject 2
0003 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0005 leave\n"

Try a method:

ruby -e '
class Person
  def greet
    "hello"
  end
end

puts RubyVM::InstructionSequence.compile(
  "Person.new.greet"
).disasm
'

You will see that Ruby source code has already been transformed into a lower-level instruction sequence before execution.

The exact instructions will depend on your Ruby version, so don’t treat a particular disassembly listing as universal. Ruby explicitly warns that instruction sequences are version-dependent. (docs.ruby-lang.org)


15. The complete mental model

As a senior Ruby developer, I find this model much more useful than simply saying “Ruby is interpreted.”

                   Ruby Program
                        │
                        ▼
                 Ruby Executable
                        │
                        ▼
                    Parser
                        │
                        ▼
                    Compiler
                        │
                        ▼
               YARV Bytecode
                        │
                        ▼
             ┌──────────────────┐
             │     CRuby VM      │
             └────────┬─────────┘
                      │
             ┌────────┴────────┐
             │                 │
             ▼                 ▼
        Interpreter          YJIT
             │                 │
             ▼                 ▼
       Native runtime     Native machine code
             │                 │
             └────────┬────────┘
                      ▼
                  CPU executes
                      │
                      ▼
               Memory / OS / I/O

So when I run:

ruby person.rb

the CPU isn’t magically executing Ruby syntax.

The operating system starts a native Ruby process.

That process parses my Ruby source, compiles it into VM instructions, and the CRuby runtime executes those instructions – potentially compiling hot code to native machine code through JIT.

And that brings us right back to why learning C is so valuable.

When you understand C, pointers, memory, functions, stacks, machine instructions and system calls, the Ruby runtime stops looking like a black box.

It becomes another program.

A very sophisticated program – but still a program running on a machine.

And that is exactly where I want to go next: inside the Ruby object model itself – VALUE, RBasic, object headers, heap allocation and how a simple Person.new becomes a real object in memory.

The natural next article is “What does Person.new actually create inside CRuby?” – connecting the Ruby object model to C structs, VALUE, object headers, heap slots and garbage collection.

Happy Rubying! ~