在多线程并发编程中 synchronized 和 volatile 都是很重要的,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的 “可见性” ,其中可见性是指当一个线程修改了一个共享变量时,另一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比syschronized的使用成本和执行成本更低,因为它不会引起线程上下文切换和调度。
volatile定义:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。 Java语言提供了volatile,在某种情况下比锁要更加方便。如果一个字段被声明成volatile,Java内存模型确保所有线程看到这个变量的值是一致的。
实现原理:如果一个变量使用volatile修饰之后,在进行编译生成汇编代码时,会在前面添加 Lock 指令。Lock指令在多核处理器下会引发两件事情:
在讲完实现原理之后,我感觉很有必要介绍volatile的特性:可见性、非原子性、有序性
/*
生成10个线程,并进行累加操作
*/
package test1;
public class Test {
private volatile static int i = 0;
private static void increase() {
i++;
}
public static void main(String[] args) {
for (int k = 0; k < 10; k++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
increase();
}
}
});
t.start();
}
while (Thread.activeCount() > 1) // 保证前面的线程都执行完
Thread.yield();
System.out.println(i);
}
}
在运行完程序发现:每次运行的结果都是不一样的,且每个结果值都是小于10000的值。按理来说,使用了volatile修饰的变量i,在每次的累加操作时,应该确保使用的是最新的值啊,为什么这里会产生错误?问题就出现在这:volatile不能保证原子性,它只能保证每次读取的值是最新值,但不能保证累加操作是原子性操作。累加操作包括读取变量的原始值、进行加1操作、写入内存。则进行累加操作需要3个步骤完成。可能会产生下面过程:
1、假如某个时刻变量 i 的值为10
2、线程1对变量进行自增操作,线程1先读取了变量 i 的原始值,然后线程1被阻塞了,并未完成加1操作;
3、然后此时线程2对变量进行自增操作,线程2也去读取变量 i 的原始值,由于线程1只是对变量 i 进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量 i 的缓存行无效,所以线程2会直接去主存读取 i 的值,发现 i 的值时10,然后进行加1操作,但并没有写入内存
4、然后线程1接着进行加1操作,由于已经读取了 i 的值,注意此时在线程1的工作内存中 i 的值仍然为10,所以线程1对 i 进行加1操作后 i 的值为11
5、线程1和线程2同时将i的值写入到内存,导致最终结果还是为11
那么两个线程分别进行了一次自增操作后,i 只增加了1。
在解决这个原子性问题时,如果我们想让这个++操作是正常的不出现错误,有2种措施:
1、使用synchronized进行同步increase( )方法
2、使用Atomticinteger 修饰变量 i
措施1:
/*
使用synchronized修饰自增方法,加上同步锁
*/
package test1;
public class Test {
private volatile static int i = 0;
private static synchronized void increase() {
i++;
}
public static void main(String[] args) {
for (int k = 0; k < 10; k++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
increase();
}
}
});
t.start();
}
while (Thread.activeCount() > 1) // 保证前面的线程都执行完
Thread.yield();
System.out.println(i);
}
}
措施2:
/*
使用原子性操作的结果,正确运行
*/
package test1;
import java.util.concurrent.atomic.AtomicInteger;
public class Test {
//使用AtomicInteger保证每一次的操作是原子性的
private static AtomicInteger i = new AtomicInteger(0);
private static void increase() {
i.getAndIncrement();
}
public static void main(String[] args) {
for (int k = 0; k < 10; k++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
increase();
}
}
});
t.start();
}
while (Thread.activeCount() > 1) // 保证前面的线程都执行完
Thread.yield();
System.out.println(i);
}
}
public class Singleton {
private Singleton() { }
private volatile static Singleton instance;
public Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}
这里为什么要加volatile了?我们先来分析一下不加volatile的情况,有问题的语句是这条
instance = new Singleton();
这条语句实际上包含了三个操作:1.分配对象的内存空间;2.初始化对象;3.设置instance指向刚分配的内存地址。但由于存在重排序的问题,可能有以下的执行顺序:
如果2和3进行了重排序的话,线程B进行判断if(instance==null)时就会为true,而实际上这个instance并没有初始化成功,显而易见对线程B来说之后的操作就会是错得。而用volatile修饰的话就可以禁止2和3操作重排序,从而避免这种情况。volatile包含禁止指令重排序的语义,其具有有序性。
说到synchronized,我感觉每一个搞开发的都对它太熟悉太熟悉了。那我们就同时学习一下这个元老级别的锁,很多人都称呼它为重量级锁 (后面会介绍到,关于偏向锁->轻量级锁->重量级锁 的过程)。但是,在JavaSE1.6之后,对synchronized进行了各种优化,导致也不是那么重了。在引入这个所谓重量级锁的前提,想必应该了解一下,在锁进行不断的变化过程中,它的变化过程,也即 锁的存储结构和升级过程。
在了解锁的原理之前,应该了解 Java对象头 ,这是一个很重要的概念。
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果是非数组类型,则用2个字宽存储对象头。在32位虚拟机中,1字宽等于4个字节,即32bit,Java对象头如表的长度如图所示。
Java对象头的 Mark Word里默认存储对象的HashCode或锁的信息(锁的状态、分代年龄、锁标志位)。32位JVM的Mark Word的默认存储结构如下表所示。
在运行期间,Mark Word里存储的数据会随着标志位的变化而变化。Mark Word可能变化位存储以下4种数据。
从上面的红色框框,可以看出在线程在每一次的切换时,都会判断该对象是否有锁标记位,通过标记位可以识别出当前状态。
JavaSE1.6位了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和**“轻量级锁”** ,锁一共有4种状态,级别从高到低依次是:无锁状态–>偏向锁–>轻量级锁–>重量级锁。这几个状态会随着竞争情况逐渐加强。锁可以升级,但一旦升级之后,就无法降级,表明能从低级锁升级成为高级锁之后,但无法从高级锁降级位低级锁,这种只升不降的策略是为了提高获得锁和释放锁的效率。
前面也介绍了,锁在进行竞争之时,会不断升级,会产生不同的状态锁。
大多数情况下,锁不仅存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁 ,当一个线程访问同步块并获取锁时,会向对象头和栈帧中的锁记录里存锁偏向的线程ID,以后该线程进入和退出同步块时不需要进行CAS操作(上篇文章介绍过)来加锁和解锁。只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得锁。如果测试失败,则需要再测试一下Mark Word种偏向锁的标识是否设置为1(表示当前是偏向锁);如果没有设置,使用CAS竞争锁;如果设置了,则尝试使用CAS将对象偏向锁指向当前线程。
偏向锁的获得和撤销:
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待安全点(在这个时间点上没有正在执行的字节码)。首先暂停拥有偏向锁的线程,然后检查持有锁的线程是否还活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程还处于活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的MarkWord要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
偏向锁在Java6和Java7里默认是开启的,但是它在应用程序启动几秒后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果确定在应用程序里所有的锁通常存在竞争,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
在前面花了大篇幅讲解了锁的各种状态,介绍了锁从一步一步慢慢演化到了重量级锁,利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体有如下3种形式:
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须先释放锁。
同步代码块是使用Monitorenter和Monitorexit指令实现的,而方法同步是使用另外一种方法实现的。但是,方法的同步同样可以使用这两个指令来实现。
下面分别从不同的方法实现上面所介绍的锁方式;
同步代码块实现锁:
/*
使用代码块实现同步锁
*/
package test2;
class TicketSale implements Runnable {
private int ticket = 25;
@Override
public void run() {
while (true) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (this) {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "卖了:" + ticket);
ticket--;
} else {
break;
}
}
}
}
}
public class Test1 {
public static void main(String[] args) {
TicketSale ticketSale = new TicketSale();
Thread[] t = new Thread[10];
for (int i = 0; i < 10; i++) {
t[i] = new Thread(ticketSale);
t[i].start();
}
}
}
运行结果:
Thread-5卖了:25
Thread-2卖了:24
Thread-1卖了:23
Thread-3卖了:22
Thread-6卖了:21
Thread-7卖了:20
Thread-4卖了:19
Thread-9卖了:18
Thread-8卖了:17
Thread-0卖了:16
Thread-3卖了:15
Thread-6卖了:14
Thread-5卖了:13
Thread-1卖了:12
Thread-7卖了:11
Thread-0卖了:10
Thread-2卖了:9
Thread-8卖了:8
Thread-4卖了:7
Thread-9卖了:6
Thread-1卖了:5
Thread-7卖了:4
Thread-5卖了:3
Thread-8卖了:2
Thread-4卖了:1
此时通过同步代码块实现了正确的卖票过程。
同步方法实现锁:
/*
使用锁方法实现锁
*/
package test2;
class TicketSale implements Runnable {
private static int ticket = 25;
@Override
public void run() {
while (ticket > 0) {
sell();
}
}
public synchronized void sell() {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "卖了:" + ticket);
ticket--;
}
}
}
public class Test2 {
public static void main(String[] args) {
TicketSale ticketSale = new TicketSale();
Thread[] t = new Thread[10];
for (int i = 0; i < 10; i++) {
t[i] = new Thread(ticketSale);
t[i].start();
}
}
}
实现结果:
Thread-2卖了:25
Thread-2卖了:24
Thread-2卖了:23
Thread-2卖了:22
Thread-2卖了:21
Thread-2卖了:20
Thread-2卖了:19
Thread-1卖了:18
Thread-1卖了:17
Thread-1卖了:16
Thread-1卖了:15
Thread-1卖了:14
Thread-1卖了:13
Thread-0卖了:12
Thread-0卖了:11
Thread-0卖了:10
Thread-0卖了:9
Thread-0卖了:8
Thread-0卖了:7
Thread-0卖了:6
Thread-0卖了:5
Thread-0卖了:4
Thread-0卖了:3
Thread-0卖了:2
Thread-0卖了:1
在解决上面的问题时,出现了很多的小问题,针对synchronized后面会继续更新,当前只是做了一个简单的实现。后面会针对一半同步,一半异步、代码间的同步、与静态同步synchronized方法和synchronized(this) 等之间的验证比较。