在上一篇幅中有说到覆写Runnable()接口实现多线程,在里面有写一段代码,如果有运行那段代码的朋友可能会发现在多线程调用一个方法时,里面的当前剩余票数会存在重复的问题;关于这个问题的发生以及怎么解决就是这篇幅要解决的问题。
在讲这问题前我再重新写一个例子来进行说明:运行下面这段代码,理论上来说输出结果最终是1000,但是实际上不是;
public class Demo1 { public static int count=0; public static void incr(){ try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } count++; } public static void main( String[] args ) throws InterruptedException { for(int i=0;i<1000;i++){ new Thread(()->Demo1.incr()).start(); } Thread.sleep(3000); System.out.println("运行结果:"+count); } }
导致上面问题出现的原因有两个,线程的可见性、原子性
一. 多线程的三大特性
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。Java提供了volatile关键字来保证可见性。
3. 有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。 在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
二 .原子性的原理分析
我们由开篇的例子及前面的三大特性的解释,那么我们进一步就开篇的代码问题进行深思,我们开篇的例子,原子性根据定义可以发现体现在count++的时间片切换上,为了解决这个问题我们可以用锁来解决问题
1.Synchronized同步锁
public class SynchronizedDemo { synchronized void demo(){ } void demo03(){ synchronized (this){ //线程安全性问题. } } public static void main(String[] args) { SynchronizedDemo synchronizedDemo=new SynchronizedDemo( ); //锁的互斥性。因为两个线程是访问同一个实体对像资源所以当其中一个线程获取了访问权另一个只能等待,如果
//变成两个实体对像的话就不存在互斥性 new Thread(()->{ synchronizedDemo.demo(); },"t1").start(); new Thread(()->{ synchronizedDemo.demo(); },"t2").start(); } }
但是下面的方法是两个线程访问同一个静态方法,锁照常能达到互斥性
public class SynchronizedDemo { synchronized void demo(){ } void demo03(){ synchronized (this){ //线程安全性问题. } } //-------------//类锁. 针对所有对象都互斥 synchronized static void demo04(){ } public static void main(String[] args) { SynchronizedDemo synchronizedDemo=new SynchronizedDemo( ); SynchronizedDemo synchronizedDemo2=new SynchronizedDemo( ); //锁的互斥性。 new Thread(()->{ synchronizedDemo.demo04(); },"t1").start(); new Thread(()->{ synchronizedDemo2.demo04(); },"t2").start(); }
下个实例,我们两个对像访问同一个类也能达到互斥性
public class SynchronizedDemo { synchronized void demo(){ } void demo03(){ synchronized (this){ //线程安全性问题. } } //-------------//类锁. 针对所有对象都互斥 synchronized static void demo04(){ } void demo05(){ synchronized (SynchronizedDemo.class){ } } public static void main(String[] args) { SynchronizedDemo synchronizedDemo=new SynchronizedDemo( ); SynchronizedDemo synchronizedDemo2=new SynchronizedDemo( ); //锁的互斥性。 new Thread(()->{ synchronizedDemo.demo05(); },"t1").start(); new Thread(()->{ synchronizedDemo2.demo05(); },"t2").start(); } }
由上面我们可以发现,我们锁的对像范围可以影响到我们锁的资源,synchronized有三种方式来加锁,不同的修饰类型,代表锁的控制粒度:
1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
通过上面的锁的讲解,我们对保证原子性有了一定的了解,下面我们再来优化下我们开篇的代码内容:
优化1:
public class Demo1 { public static int count=0; //方法上加锁 public synchronized static void incr(){ try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } count++; } public static void main( String[] args ) throws InterruptedException { for(int i=0;i<1000;i++){ new Thread(()->Demo1.incr()).start(); } Thread.sleep(3000); System.out.println("运行结果:"+count); } }
优化2:
public class Demo1 {
public static int count=0;
public static void incr(){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//类锁
synchronized (Demo1.class) {
count++;
}
}
public static void main( String[] args ) throws InterruptedException {
for(int i=0;i<1000;i++){
new Thread(()->Demo1.incr()).start();
}
Thread.sleep(3000);
System.out.println("运行结果:"+count);
}
}
优化3
public class Demo1 { public static int count=0; static Object object=new Object(); public static void incr(){ try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } //类锁 synchronized (object) { count++; } } public static void main( String[] args ) throws InterruptedException { for(int i=0;i<1000;i++){ new Thread(()->Demo1.incr()).start(); } Thread.sleep(3000); System.out.println("运行结果:"+count); } }
二.锁的存储
上面我们聊了锁的互斥的本质,如果我们想要了解互斥的本质是什么,那么我们就要了解synchronized加锁在JVM中是如何实现的,为止我们引出了锁的存储。我们知道JVM中在方法区存储对象的引用,在堆中存储的对象实例。那么堆中存储的对象又有那些信息哪?其实堆中存储的对象主要由三部分组成,对象头,实例字段数据以及padding。对象头里面存储了指向方法区元数据的引用,实例字段数据就是存储了实际的字段数据,padding主要是为了补位,实例对象在堆中存储的时候必须是八字节的整数倍,不够的时候由padding占位补齐。
对象头中的数据有具体分为Mark World,Class Metadata Address以及Array Length
- Mark World : 一段32/64的内存区域,用来存储Hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等
- Class Metadata Address : 指向类的元信息的引用
- Array Length : 如果是数组对象,会有一个Array Length用来标记数组的长度
为了更深入的了解锁的状态,我们引入包来打印类的布局
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
public class Demo2 {
public static void main(String[] args) {
Demo2 demo2=new Demo2();
System.out.println("输出");
System.out.println(ClassLayout.parseInstance(demo2).toPrintable());
}
}
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000 0 01 (64位二进制)
我们可以看到上图的内存布局,通过打印输出我们可以知道它是一个无锁状态
我们改下代码
public class Demo2 { public static void main(String[] args) { Demo2 demo2=new Demo2(); synchronized (demo2){ System.out.println("输出"); System.out.println(ClassLayout.parseInstance(demo2).toPrintable()); } } }
我们观察最后三位,发现是000,000代表轻量级锁。
1.无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
2.偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会 首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
3.轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
4.重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
三.线程的通信(wait/notify)
通过前面两篇幅的讲解,我们对多线程和锁的力度有了一定的了解,下面我们就我们前面了解的内容做一个小的实例,相信大家都用过通信队列,例如MQ,下面我们就仿照原理做一个小的DEMO;
//生产者 public class Production implements Runnable{ //消息队列 private Queue<String> queue; private int maxSize; public Production(Queue<String> queue, int maxSize) { this.queue = queue; this.maxSize = maxSize; } @Override public void run() { int i=0; while (true){ i++; //锁同一个对像 synchronized (queue){ while (queue.size()==maxSize){ try { queue.wait();//如果消息队列满了就阻塞 } catch (InterruptedException e) { e.printStackTrace(); } } try { Thread.sleep(1000); }catch (InterruptedException e){ e.printStackTrace(); } System.out.println("生产的消息号是:"+i); queue.add("生产了第"+i+"条消息"); queue.notify();////唤醒处于阻塞状态下的线程 } } } }
/消费者 public class Customer implements Runnable{ private Queue<String> queue; private int maxSize; public Customer(Queue<String> queue, int maxSize) { this.queue = queue; this.maxSize = maxSize; } @Override public void run() { while (true){ synchronized (queue){ //如果消息队列空了 while (queue.isEmpty()){ try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("消费者在消费"+queue.remove()); queue.notify();//唤醒处于阻塞状态下的生产者 } } } }
public class Test { public static void main(String[] args) { Queue<String> queue=new LinkedList<>(); int maxSize=5; Customer customer=new Customer(queue,maxSize); Production production=new Production(queue,maxSize); Thread thread1=new Thread(customer); Thread thread2=new Thread(production); thread1.start(); thread2.start(); } }
通过上面简单的例子我们就把上次讲的内容和这次讲的内容进行了一个小的连接做出了个简单的生产消费例子