线程安全可以概括为三个方面:原子性、可见性和有序性。
原子性:对于涉及共享变量的操作看做一个整体,在同一时间内,只能由一个线程执行,在其它线程看来,这部分操作要么尚未开始,要么已经完成。Java中,基本类型除了long和double,其它类型变量的写操作都是原子性的。
可见性:一个线程修改了共享变量后,其它线程能够立即看见改变后的值。
有序性:即程序按照代码的先后顺序执行。我们写好的代码在执行的时候不一定是按照顺序的,因为虚拟机编译的时候,在保证输出结果不变的情况下可能对代码进行优化,也就是常说的指令重排。在单线程的情况下不会有什么影响,但是多线程的环境下则会有隐患。
Volatile
先来看Java内存模型
线程先从主内存读取到变量,对变量进行修改之后再刷新回主内存。当使用了volatile之后:线程直接读写主内存。
这就保证了共享变量在线程间的可见性。一个常见的例子就是使用volatile修饰的变量,线程A通过改变变量的值去停止另一个正在运行的线程B。
class MyTask implements Runnable{
private volatile boolean flag = true;
public void stop(){
flag = false;
}
@Override
public void run() {
System.out.println("====进入循环====" + flag);
while (flag){
}
System.out.println("====停止循环====" + flag);
}
}
测试
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 15, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(5));
MyTask task = new MyTask();
executor.execute(task);
Thread.sleep(2000);
System.out.println("外部调用stop");
task.stop();
}
}
如果不加volatile关键字,则不能停止线程。因为其它线程不能立即看见改变。另外volatile也保证了有序性。
对于volatile变量的写操作,JVM会在该操作之前加入一个释放屏障,操作之后加入一个存储屏障。
释放屏障禁止了volatile写操作和该操作之前的任何读写操作进行重排序。这就保障了实际执行顺序和源代码顺序一样,即保障了有序性。
volatile虽然能够保障有序性,但是不具有锁那样的排他性,只能够保证所修饰变量写操作的原子性,不能保证其他操作的原子性。
对于volatile的读操作,JVM会在该操作之前加入一个加载屏障。,操作之后加入一个获取屏障。
volatile总结:
- volatile变量的写操作与该操作之前的任何读写操作不会被重排序。
- volatile变量的读操作与该操作之后的任何读写操作不会被重排序。
- volatile只能保证可见性和有序性,对于包含多个操作的共享区域,不能保证线程安全。
Synchronized
先来个简单代码
package com.demo.tools;
public class Demo {
public void hello(){
synchronized(this) {
System.out.println("Hello");
}
}
}
编译之后进入classes目录,查看编译后的结构。
可以看到在代码中用synchronized包起来的代码块前后有两个东西:monitorenter和monitorexit,这就涉及了一个叫做Monitor的东西:我们知道对象在内存中分为三个区域(对象头、实例变量,填充数据),而这个Monitor则存储在对象头里。
当执行到monitorenter,线程尝试获取锁(也就是抢占Monitor);也因为Monitor存在对象头里,所以解释了为什么Java中任意对象都可以作为锁。其中还有个计数器,当为0的时候代表可以获取,当线程获取到了,计数器+1,当执行到monitorexit的时候,线程不占有这个锁,计数器-1。由于Synchronized是可重入锁,也就是在持有当前锁的基础上继续获取当前锁,是可以的,这个时候,计数器继续+1,退出同步区域则-1,直到为0。
同时Synchronized还维护了一个入口集(Entry Set),这个集合存放等待的线程。
引申
JDK1.6的时候,JVM团队做了一系列的锁优化,所以现在的synchronized在一些情况中性能不比ReentrantLock差:
我们都知道线程的开销主要是上下文切换,挂起线程和恢复线程的操作都需要转入内核态中完成。而很多情况是:共享数据的锁定情况只会持续很短的一段时间,根本不值得挂起恢复。自旋锁由此而来:
1.自旋锁:线程不放弃处理器的执行时间,为了让线程等待,而是去执行一个忙循环(自旋)。
当然,虽然避免了上下文切换的开销,但是它也是占用处理器时间的:如果锁占用时间很短,那么就达到了自旋的目的;反之占用时间很长,那么自旋线程只会白白浪费处理器资源。所以自旋通常都有个限制,自旋次数默认是10次。
2.在JDK1.6中引入了适应性自旋:
自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
说白了就是前事不忘后事之师,看到前面那个自旋成功,则本次也认为能够自旋成功;如果前面自旋失败,则本次也八九不离十失败,干脆省略自旋,直接挂起。
3. 锁消除:指虚拟机即时编译器在运行时,对一些代码上被检测到不可能存在共享数据竞争的锁进行消除。
4. 偏向锁:这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束,进而转变为轻量级锁。偏向锁可以提高带有同步但无竞争的程序性能。
5. 轻量级锁:“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。
对于synchronized的升级过程:
①:第一次执行到synchronized代码块的时候,锁对象是偏向锁。当线程执行完同步代码块,不释放锁,如果下一次又执行到了同步代码块,先判断持有锁的线程是不是自己(当前持有锁的线程ID存储在对象头里),如果是自己就不用重新加锁;否则就是有人来抢了,此时偏向锁升级为轻量级锁。
②:在轻量级锁上继续竞争,没有抢到锁的线程自旋,抢到锁的线程把锁对象的对象头里的线程ID更改为自己。自旋的线程在白白的消耗CPU,这种状态叫busy-waiting,超过了最大自旋次数的限制后,会将轻量级锁升级为重量级锁。后面再来线程尝试获取锁,发现是个重量级锁,就把自己挂起,等待被唤醒恢复。
总结:
之所以说synchronized不好,因为在1.6之前的synchronized直接是重量级锁,然后锁的是整个对象,不如ReentrantLock零活粒度细。而现在的synchronized经过优化之后性能以及很好了,ConcurrentHashMap都在用。还有就是synchronized只能按照偏向锁->轻量级锁->重量级锁的顺序逐渐升级(锁膨胀),不允许降级。
另外,synchronized和ReentrantLock都是可重入锁(允许同一个线程多次获取同一把锁);synchronized是不可中断锁,Lock接口的实现类比如ReentrantLock都是可中断锁。
Volatile和Synchronized区别
- volatile只能修饰变量,synchronized可以修饰方法和代码块。
- 多线程访问volatile不会阻塞,synchronized会阻塞。
- volatile只保证了变量在多个线程之间的可见性,但不能保证原子性;而synchronized可以保证原子性,也可以间接保证可见性,因为它会将工作内存和主内存中的数据做同步处理。