1. Java内存模型(JMM)
1.1 主内存与工作内存
所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。
1.2 内存间交互操作
- read:把一个变量的值从主内存传输到工作内存中
- load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
- use:把工作内存中一个变量的值传递给执行引擎
- assign:把一个从执行引擎接收到的值赋给工作内存的变量
- store:把工作内存的一个变量的值传送到主内存中
- write:在 store 之后执行,把 store 得到的值放入主内存的变量中
1.3 内存模型三大特性
1.3.1 原子性
定义
Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性。
实现
- synchronized对象锁保证了原子操作。
Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性。
例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的,一般来讲读取和返回基本类型都是原子性的,而自增不是;但是JMM允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
1.3.2 可见性
定义
可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
实现
- volatile,会将运算结果立即写入主存
- synchronized,对一个变量执行unlock 操作之前,必须把变量值同步回主内存,lock操作之前也会从主存中读取新数据。(由指令强制行为)
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成(重排序保证),并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
volatile关键字
被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。(会将运算结果立即写入主存)
写
:
如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。读
:
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
1.3.3 有序性
定义
在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
实现
- volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
- 通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
1.4 先行发生原则
时间先后不代表先行发生(指令重排序),先行发生不代表时间先行。
- 在一个线程内,在程序前面的操作先行发生于后面的操作。
- 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
- 对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
- Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
- 线程中的所有操作先行发生于对此线程的终止检测,即Thread 对象的结束先行发生于 join() 方法返回。
- 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
- 一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
- 如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
2. 线程安全/不安全
2.1 不可变
定义
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
不可变类型
- final 关键字修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
- 对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
2.2 互斥同步
定义
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁。
实现
- synchronized
- ReentrantLock
2.3 非阻塞同步
2.3.1 定义
非阻塞同步是不需要将线程阻塞的基于乐观锁的并发策略,先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)
2.3.2 CAS(硬件层面实现)
定义
CAS基于乐观并发策略,乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。
原理
硬件支持比较并交换这两个原子性:CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
应用场景
在J.U.C包中利用CAS实现类有很多,可以说是支撑起整个concurrency包的实现,如:在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现
问题
- ABA问题:一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。
- 自旋时间过长:使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。
2.3.3 atomic包(通过CAS操作保证原子性)
- 原子更新基本类型
- 原子更新数组类型
- 原子更新引用类型
- 原子更新字段类型
- 通过CAS操作保证原子性
2.4 无同步方案
2.4.1 栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
2.4.2 线程本地存储(Thread Local Storage)
定义
表示线程的“本地变量”,即每个线程都拥有该变量副本,达到人手一份的效果,各用各的这样就可以避免共享资源的竞争。这就是一种“空间换时间”的方案,每个线程都会都拥有自己的“共享资源”无疑内存会大很多,但是由于不需要同步也就减少了线程可能存在的阻塞等待的情况从而提高的时间效率。
原理
- 每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象。
- ThreadLocal.set:通过当前线程对象thread获取该thread所维护的threadLocalMap,若threadLocalMap不为null,则以threadLocal实例为key,值为value的键值对存入threadLocalMap,若threadLocalMap为null的话,就新建threadLocalMap然后在以threadLocal为键,值为value的键值对存入即可。
- ThreadLocal.get:通过当前线程thread实例获取到它所维护的threadLocalMap,然后以当前threadLocal实例为key获取该map中的键值对(Entry),若Entry不为null则返回Entry的value。如果获取threadLocalMap为null或者Entry为null的话,就以当前threadLocal为Key,value为null存入map后,并返回null。
应用场景
threadLocal只适用于 共享对象会造成线程安全 的业务场景。大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
内存泄露问题
原因
:threadlocalmap的key是弱引用,如果threadLocal外部强引用被置为null(threadLocalInstance=null)的话,threadLocal实例就没有一条引用链路可达,很显然在gc(垃圾回收)的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个Key为null去访问到该entry的value。同时,就存在了这样一条引用链:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,导致在垃圾回收的时候进行可达性分析的时候,value可达从而不会被回收掉,但是该value永远不能被访问到,这样就存在了内存泄漏。
弱引用的问题
:假设threadLocal使用的是强引用,在业务代码中执行threadLocalInstance==null操作,以清理掉threadLocal实例的目的,但是因为threadLocalMap的Entry强引用threadLocal,因此在gc的时候进行可达性分析,threadLocal依然可达,对threadLocal并不会进行垃圾回收,这样就无法真正达到业务逻辑的目的,出现逻辑错误
解决
:threadlocal每次get和set都会清理;每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
3. 锁优化
3.1 自旋锁
定义
互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用***享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
缺点
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
3.2 锁消除
定义
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
3.3 锁粗化
定义
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。
3.4 锁类型
3.4.1 对象头
HotSpot 虚拟机对象头的数据被称为 Mark Word,其中 tag bits 对应了五个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)、GC(marked for gc)。这几个状态会随着竞争情况逐渐升级,锁可以升级但不能降级
3.4.2 轻量级锁
定义
轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。但是如果存在经常竞争的线程时,那么轻量级锁反而要多执行一次CAS,反而开销增加。
对象头和栈帧
栈帧:在创建轻量级锁时创建的Lock Record,存放拷贝的MarkWord;
对象头:Mark Word存放指向Lock Record的指针
加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建Lock Record,并将对象头中的Mark Word复制到锁记录中,称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,如果成功,当前线程获得锁,如果失败,判断是否MarkWord是否指向本线程,是则继续执行同步块,否则表示其他线程竞争锁,膨胀为重量级锁。
解锁
会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,在释放锁的同时唤醒被阻塞的线程。
3.4.3 偏向锁
定义
偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要,使用于只有一个线程的场景。
对象头和栈帧
栈帧:无;
对象头:保存指向线程ID号的Mark Word
加锁
访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态,如果为可偏向状态,则测试线程ID是否指向当前线程,如果是执行同步代码;如果线程ID并未指向当前线程,则通过CAS操作竞争锁;如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行同步代码;如果竞争失败,执行当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁膨胀为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
解锁
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。