java内存模型是java虚拟机规范定义的一种特定模型,用以屏蔽不同硬件和操作系统的内存访问差异,让java在不同平台中能达到一致的内存访问效果,是在特定的协议下对特定的内存或高速缓存进行读写访问的抽象。我来简单的总结成一句话就是:java内存模型是java定义的对计算机内存资源(包含寄存器、高速缓存、主存等)的读写方法和规则。 注意上面定义是我个人的理解。
随着我们计算机技术的不断发展,计算机的运算能力越来越强,cpu和存储及通信子系统的速度差距越来越大,为了避免将大量宝贵的计算资源浪费在数据库查询、网络通信等IO操作上,现在多线程开发已经成了我们必需的技能。而多线程开发面临的最大问题就是数据一致性问题,线程之间如何读到各自的数据?线程之间如何进行交互?这些都是很重要的问题。另外编译器在编译程序时会自动对程序进行重排序,cpu在执行指令时也会通过指令乱序的方式来提高执行效率,高速缓存也会导致变量提交到内存的顺序发生变化,同时不同处理器高速缓存中的数据互相不可见,这些都导致从一个线程看另一个线程,另一个线程的内存操作似乎在乱序执行。
为了解决这些问题,java内存模型规定了一组最小保证,这组保证规定了对变量的写入操作在何时对其他线程可见,同时也会保证在单线程环境中程序的执行结果与在严格串行环境中执行的结果相同(在本线程中好像顺序执行一样)。
jvm虚拟机的主要目标是定义共享变量的访问规则,java内存模型在设计时为了保证性能在可预测性和易开发性间进行了平衡,它并没有限制编译器的重排序优化,也没有限制执行引擎使用处理器的寄存器和缓存与主存进行交互,在跨线程的共享数据处理中,我们仍然需要使用合适的同步操作访问共享数据。
在java内存模型中定义了“主内存”和“工作内存”两个概念,我们可以将主内存类比为我们计算中的内存,将工作内存类比为我们cpu中的高速缓存和寄存器,但是实际上他们并不是等价关系。java内存模型规定:所有变量都储存在主内存中,线程对变量的所有操作都必须在工作线程中,每个线程都有自己的工作内存,他们之间互相无法访问,线程间的交互需要通过主内存。
在主内存和工作内存的基础上,java内存模型定义了8个最基本的原子操作,用以处理主内存和工作内存的交互。
在日常的开发中我们经常能听大家谈论volatile关键字,但实际上具体是如何实现的大部分人都不清楚,实际上原理并不复杂。volatile具备两个关键的特性,一个是保证变量对所有线程的可见性,另一个是禁止指令重排序(包括cpu层面的指令乱序)。volatile抽象逻辑上通过“内存栅栏”实现,其使用的“栅栏”如下所示:
每个volatile写操作前会插入StoreStore栅栏,写操作后会插入StoreLoad栅栏
每个volatile读操作前会插入LoadLoad栅栏,读操作后会插入LoadStore栅栏
在字节码层面,volatile通过lock指令实现,在volatile变量写操作后会有一个lock addl ¥0x0, (%esp) 的命令,这个命令会将变量数据立即刷到主内存中,并利用cpu总线嗅探机制使其他线程高速缓存内的cacheline失效(cacheline是cpu高速缓存cache的基本读写单位),使用时必须重新到主内存Memory读取。同时因为需要立刻刷数据到内存中,那么volatile变量操作前的所有操作都需要完全执行完成,这样进而也保证了volatile变量写操作前后不会出现重排序。通常volatile变量的读写效率和普通变量没有多大差别,但在volatile变量并发访问冲突非常频繁的情况下可能造成性能的下降,具体的例子及解决方案可以百度“伪共享”问题。
java内存模型主要是通过各种操作的定义实现的,包括内存变量的读写操作、监视器锁定释放、线程关闭启动等等。java内存模型为所有的这些操作定义了一套偏序关系,我们称之为先行发生规则(happens-before)。线程A要看到线程B的结果,那么线程A和线程B必须满足happens-before原则,如果不满足就可能会出现重排序。下面是具体的规则:
注意先行发生并不代表时间上的先后! 举两个小
(1)函数A进行set操作,函数B进行get操作,即时时间上A先执行B后执行,B也不不一定能读到A set的值,很可能刚好函数A指令还没执行好,线程的时间片就没了,然后B函数获得了cpu时间片并执行完成,这时B函数根本读不到A设置的值。
(2)另外即使是A操作先行发生于B操作,那么A操作也不一定在时间上先于B操作执行,假如AB间没有依赖关系,那么很可能在时间上B先执行,因为jvm只会帮我们保证最终的执行结果与严格顺序执行的结果相同,不存在依赖关系的变量或操作间仍可能重排序优化。
在并发开发中,我们首先需要保证并发的正确性,然后在此基础上实现高效代码的开发。在日常开发中,我们通常会将能够安全的被多个线程使用的对象称为线程安全对象,但这样说可能仍不够严谨,我们可以借用《java并发编程实战》中的定义:当多个线程访问一个对象时,如果不用考虑线程在运行时环境的调度和交替执行,也不需要额外的同步和调用方的操作协调,直接使用这个对象都能获得正确的结果,这个对象就是线程安全的。
通常在java中我们可以将java按安全性强弱分为几个级别:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立,接下来我们分别简单的介绍下。
// ThreadLocal使用方式
ThreadLocal<String> threadLocalA = new ThreadLocal<>();
threadLocal.set("东哥真帅!");
// ThreadLocal.set源码
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
// ThreadLocalMap.set部分源码
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
........
}
}
........
// ThreadLocalMap类部分源码
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
........
}
........
值得注意的是ThreadLocalMap并没有使用拉链法,而是使用了线性探测法,并且为了提高key的离散度/减少key冲突,没有使用对象自身的HashCode,而是使用了自定义的threadLocalHashCode。
另外还有一点非常重要:ThreadLocalMap的key被封装成了弱引用。当ThreadLocal对象threadLocalA没有其他强引用时,在下次GC来临时threadLocalA就会被回收,同时ThreadLocalMap相应槽位的key值会变为null,ThreadLocalMap在每次进行get/set操作时都会主动的去清空key为null的键值对。ThreadLocal的这种设计主要是为了防止出现内存泄露。假如key为强引用,那么当threadLocalA使用完后,ThreadLocalMap仍持有threadLocalA的强引用,将会导致threadLocalA无法回收。
顺便提下java中的几种引用类型,主要有强引用、软引用、弱引用、虚引用。相关的知识可以参见:Java 的强引用、弱引用、软引用、虚引用。
在并发程序中,对伸缩性的最主要威胁就是独占方式的资源锁。在独占锁上发生竞争将导致线程操作串行化和大量上线文切换,所以尽量降低和减少锁的竞争可以提升性能以及提高程序的可伸缩性。影响锁竞争的两个最重要的因素是:1.锁的请求频率,2.锁的持有时间。接下来介绍几种减少锁竞争的方案。
private final ReentrantLock takeLock = new ReentrantLock();
private final Condition notEmpty = takeLock.newCondition();
private final ReentrantLock putLock = new ReentrantLock();
private final Condition notFull = putLock.newCondition();
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
if (e == null) throw new NullPointerException();
long nanos = unit.toNanos(timeout);
int c = -1;
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
while (count.get() == capacity) {
if (nanos <= 0)
return false;
nanos = notFull.awaitNanos(nanos);
}
enqueue(new Node<E>(e));
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
return true;
}