写在前面
多线程访问共享变量的时候,很容易出现并发问题。特别是多个线程对共享变量进行写入的时候,由于原子性的问题,很容易导致最后数据的错误。一般来讲,我们可以进行同步,同步的方式就是加锁。另一方面,jdk也提供了变量的线程隔离方式——ThreadLocal,尽管它的出现并不是为了解决上述的问题。
共享变量
何为共享变量?说到这个,我们想一想什么变量不是共享的。在一个线程调用一个方法的时候,会在栈内存上为局部变量和方法参数申请内存,在方法调用结束的时候,这些内存会被释放。不同的线程调用同一个方法都会为局部变量和方法参数copy一个副本,所以栈内存是私有的,也就是说局部变量和方法参数不是线程共享的
。而堆上的数组和对象是共享的,堆内存是所有线程可以访问的,也就是说成员变量,静态变量和数组元素是可以共享的
。
原子性
即不可中断的一个或一系列操作,也就是说一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。在线程级别,我们可以这样说一个或几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行
。
举个栗子:i++
,这个操作的语义可以拆分成三步:
- 读取变量i的值
- 将变量i值加1
- 将计算结果写入变量i
由于线程是基于处理器分配的时间片执行的,这三个步骤可能让多个线程交叉执行。我们假设i
的初始值为0
,如果两个线程按照如下顺序交替执行的话:
我们看到,经过了两次i++
的操作,变量i
最后的值是1
,并不是想象中的2
。这就是因为i++
并不是原子性操作所带来的并发问题。
解决方案
从共享性解决
使用局部变量
方法中的方法参数和局部变量是线程私有的,自然不会存在并发问题。
使用ThreadLocal
ThreadLocal示例
ThreadLocal
作为变量的线程隔离的类,访问此变量的每个线程都会copy一个此变量的副本。多个线程操作这个变量,实际上是操作的自己本地内存的变量,这样就避免了多线程操作变量的安全问题。
我们先来看一下ThreadLocal
的简单使用:
public class ThreadLocalDemo {
static ThreadLocal<String> tl = new ThreadLocal<>();
public static void main(String[] args) {
Thread thread1 = new Thread(()->{
tl.set("thread 1 locals");
System.out.println("thread 1 :"+ tl.get());
});
Thread thread2 = new Thread(()->{
tl.set("thread 2 locals");
System.out.println("thread 2 :"+ tl.get());
});
thread1.start();
thread2.start();
}
}
运行结果:
其实无论运行多少次,无论几个线程一起跑,最后打印出来的都是各个线程自己维护在内存里的本地变量,而不会出现线程1设置的变量被线程2修改这种情况。
ThreadLocal源码解读
结构
由ThreadLocal和Thread的类结构可知,Thread里面有两个成员变量threadLocals
和threadLocals
,他们都是ThreadLocalMap
类型的,而ThreadLocalMap
是ThreadLocal
的一个静态内部类,这是一个定制化的HashMap。默认每个线程一开始的时候,这两个变量都是null。
类结构图如下:
Set方法
ThreadLocal#set:
public void set(T value) {
Thread t = Thread.currentThread(); //当前线程
ThreadLocalMap map = getMap(t); //获取当前线程的threadlocals
if (map != null)
map.set(this, value); //(k,v)存入ThreadLocalMap
else
createMap(t, value); //初始化当前线程的threadlocals,创建ThreadLocalMap
}
代码的字面意思:如果当前线程的threadLocals变量不为null,则将当前的ThreadLocal实例为key,传入的value值为value,放入ThreadLocalMap对象里面;如果当前线程的threadLocals为null,则初始化当前线程的threadLocals变量。
ThreadLocalMap getMap(Thread t) {
return t.threadLocals; //返回当前线程的threadLocals
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue); //初始化当前线程的threadLocals,k为当前的ThreadLocal对象,v为设置的值。
}
看到这里,说一下为什么为什么会是个map。那是因为一个线程可以绑定多个ThreadLocal实例。例如:
static ThreadLocal<String> tl1 = new ThreadLocal<>();
static ThreadLocal<Integer> tl2 = new ThreadLocal<>();
Get方法
public T get() {
Thread t = Thread.currentThread(); //当前线程
ThreadLocalMap map = getMap(t); //当前线程的threadlocals变量
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this); //取出Entry
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
private T setInitialValue() {
T value = initialValue(); //初始化为null
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); //获取当前线程的thredLocals是变量
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
这段代码的意思是:我先判断当前线程的本地变量threadLocals
变量是否为null,不为null则以当前的ThreadLocal
实例为key从map中取出Entry,能取出Entry则返回对应的value值;若当前线程的本地变量threadLocals
变量为null,则初始化threadLocals
变量,初始化的工作和set
差不多,只不过set
设置的值为传入的参数,初始化设置的value是null(在当前线程的threadLocals变量不为null的时候)。
Remove 方法
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
就是如果当前线程的 本地变量threadLocals
不为null,则删除当前线程中指定 ThreadLocal 实例的本地变量。
总结一下:每个线程都有一个成员变量叫threadLocals,它是ThreadLocalMap类型的。其中的key为每一个ThreadLocal的实例,value为传入的参数值。
注意:
- 由于
ThreadLocalMap
的key为WeakReference
,在外部没有强引用时,发生GC会回收时,如果创建ThreadLocal
一直运行,将会导致这个key对应的value将会一直在内存中得不到回收,发生内存泄露。所以在用完ThreadLocal的时候要注意手动remove。 - 其实
ThreadLocal
会有个问题,那就是子线程通过获取不了父线程中的ThreadLocal
变量,这个其实java已经给出了解决方案了,就是Thread的另一个ThreadLocalMap类型的变量inheritableThreadLocals
,我们通过这个变量,从get方法中能获取到本线程和父线程的ThreadLocal变量。
同步方法解决
话说回来,刚刚我们从共享性角度解决并发编程的原子性问题,提出了ThreadLocal,也就是每个线程独占的,自然不会有并发问题。下面从另一个角度来说,也就是我们都知道的方式:加锁。
锁的概念
《并发编程的艺术》里是这么定义锁的:"锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)"。
提到锁,一大堆名词就冒出来了:内置锁,显示锁,可重入锁,读写锁,以及祖师爷抽象队列同步器(AQS)……
最大名鼎鼎的要属于synchronized的了。
synchronized关键字
synchronized同步关键字,可以修饰方法,使之成为同步方法。可以修饰this或Class对象,使之成为同步代码块。
public 返回类型 方法名(参数列表) {
synchronized (锁对象) {
需要保持原子性的一系列代码
}
}
public synchronized 返回类型 方法名(参数列表) {
需要被同步执行的代码
}
public synchronized static 返回类型 方法名(参数列表) {
需要被同步执行的代码
}
例如这个demo:
public class SynchronizedDemo {
private Object lock = new Object();
public synchronized void m1(){
}
public void m2(){
synchronized (lock) {
}
}
}
通过javap反编译出来:
可以看到,同步代码块的的synchronized是用monitorenter
和moniteorexit
实现的,同步方法看不出来(其实是jvm底层的的ACC_SYNCHRONIZED
实现的)。
monitorenter指令对应于同步代码块的开始位置,监视器在这个位置进入,获取锁;moniteorexit指令对应于同步代码块的结束位置,监视器在这个位置退出,释放锁。
JVM需要保证每一个monitorenter都有一个monitorexit与之对应,任何对象都有个monitor与之关联。一旦monitor被持有,这个对象将被锁定。
Java对象头
synchronized用的锁是存在java对象头里的,对象头一般占有2字宽(1字宽为4字节,即32bit),但是如果对象是数组类型,则需要3字宽。对象头里的Mark Word默认存储对象的HashCode,分代年龄和锁标记位。对象头的存储结构如下:
(此图来源于互联网,侵删)
锁的升级与优化
从jdk1.6开始,对锁进行了进一步的升级和优化。锁一共有4种状态,无锁,偏向锁,轻量级锁,重量级锁,这几种状态会随着竞争情况逐渐升级。锁可以升级但不能降级。
偏向锁
偏向锁的引入背景:只在单线程访问同步块的场景。
当锁不存在多线程竞争的时候,为了让线程获得锁的代价更低引入了偏向锁。当一个线程访问同步快并获取锁时,会在对象头Mark Word上记录偏向锁状态位1,此时的锁标识位是01。
当一个线程获取锁的时候,会先检查Mark Word上的可偏状态,如果是1,则继续检查对象头的线程Id。如果线程Id不是当前线程,则通过CAS竞争获取锁,竞争成功将线程Id替换,如果CAS竞争锁失败,证明存在多线程情况,此时偏向锁被挂起,升级升轻量级锁。如果线程是当先线程,则执行同步代码块。
偏向锁的释放使用了一种等到竞争出现才释放锁的机制,所以当其他线程去竞争锁时,持有偏向锁的线程才会释放锁。此时将恢复到无锁状态或偏向于其他线程。
轻量级锁
轻量级锁的引入背景:没有多线程竞争的前提下,减少重量级锁的互斥产生的性能消耗。
线程在执行同步块之前,JVM会首先在当前线程的栈桢中创建用于存储锁记录的空间,并通过CAS将Mark Word替换为指向锁记录的指针。如果成功,则当前线程获得锁,如果失败,则表示有其他线程竞争锁,此时会自旋等待,并会膨胀成重量级锁。
轻量级锁的释放也是通过CAS来执行的,如果成功,则表示没有竞争发生,如果失败,锁会膨胀成重量级锁。
我们发现,偏向锁相比较轻量级锁通过CAS以及自旋等方式获取锁,性能更好一些,因为他只有在判断对象头中的线程Id不是当前线程的时候才去CAS竞争锁,而轻量级锁一开始就CAS竞争锁了。
重量级锁
重量级锁通过对象内部的monitor
实现,当锁处于整个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后才会唤醒这些线程,被唤醒的线程展开新的一轮锁争夺。此时操作系统实现线程之间的切换需要从用户态到内核态的切换,线程切换的成本非常高。
参考资料
方腾飞:《Java并发编程的艺术》