类变量存储在方法区(JDK 8+为元空间),随类生命周期存在,被所有实例共享;成员变量存储在堆中,属对象实例,各实例独立。

类变量存在方法区,成员变量存在堆
Java 中的类变量(static 修饰的字段)在类加载时就分配在方法区(JDK 8+ 是元空间),随类的生命周期存在;而成员变量(非 static 字段)属于对象的一部分,每次 new 实例时,连同对象头、实例数据一起分配在堆中。
这意味着:类变量被所有实例共享,修改一个地方,所有地方可见;成员变量彼此隔离,改 A 的不影响 B 的。
- 方法区不归 GC 直接管理(类卸载才清理),所以类变量长期驻留,容易引发内存泄漏(比如缓存了大量对象引用)
- 堆上的成员变量随对象生死,受 GC 控制——但要注意:如果对象本身被强引用持有,它的成员变量也跟着“活”着
- JDK 7 及以前方法区在永久代,JDK 8+ 移到元空间(本地内存),OOM 错误从
java.lang.OutOfMemoryError: PermGen space变成java.lang.OutOfMemoryError: Metaspace
怎么验证类变量和成员变量的存储位置
不能靠 System.identityHashCode() 或 == 判断位置,得用 JVM 工具辅助观察。最直接的方式是用 jhsdb 查看运行时内存布局:
jhsdb jmap --heap --pid
输出中会显示堆内存使用情况,但看不到方法区细节;要查类变量,得结合 jhsdb clhsdb 进入调试模式,执行 inspect ,查看静态字段是否挂载在 Klass 结构下。
立即学习“Java免费学习笔记(深入)”;
- IDEA 或 Eclipse 的内存分析器(如 VisualVM 插件)能直观看到堆中对象及其字段值,但无法高亮标出“这个字段在方法区”——它只展示逻辑视图
- 用
Unsafe.objectFieldOffset()获取字段偏移量,对类变量会抛IllegalArgumentException,因为类变量没有对象内偏移——这是个简单但有效的运行时判断方式 - 反射获取
Field.get(null)成功的是类变量;Field.get(instance)才是成员变量——这背后就是 JVM 对两种存储位置的不同寻址逻辑
为什么 new 出来的对象里改不了 static 字段的值
不是“改不了”,而是你可能误以为改的是副本。类变量只有一个,所有实例访问的都是同一份内存。常见误解场景:
- 写
obj.staticField = value看似在操作实例,实际 JVM 会忽略左侧对象,直接定位到方法区的类结构去赋值 - 如果类变量是基本类型或不可变引用(如
String),看起来像“每个对象都有自己的”,其实是重新赋值覆盖了共享值,不是复制 - 真正危险的是可变对象引用(如
static List)——多个线程或实例往里面cache = new ArrayList() add,就会互相干扰,必须加锁或用Collections.synchronizedList
堆与方法区的性能和并发影响
方法区读取类变量是线程安全的(类加载完成即固定),但写操作不自动同步;堆上成员变量天然隔离,但跨线程共享对象时,其成员变量就变成共享变量,需要同步控制。
- 高频读写的类变量(比如计数器)若不做
volatile或AtomicInteger,可能因 CPU 缓存不一致导致读到旧值 - 成员变量若被逃逸分析判定为栈上分配(Escape Analysis),甚至不会进堆——但这只是 JIT 优化,逻辑上它仍属于堆语义
- 类变量初始化顺序依赖类加载时机,可能引发
NullPointerException(比如在中调用尚未初始化完毕的其他static字段)
真正容易被忽略的,是类变量的生命周期绑定类本身——只要类没被卸载,它就一直占着方法区/元空间;而很多人只盯着堆内存看 GC 日志,却忘了元空间也可能撑爆。










