1.JUC简介
在JDK1.5版本里,提供了java.util.concurrent(简称JUC)这个包,在此包中增加了许多有关于并发编程中常用的工具类。
2.基础概念
进程:进程是资源分配的最小单位(工厂)
线程:线程是CPU调度的最小单位(工人)
并发:CPU单核、多个线程操作同一资源
并行:CPU多核、多个线程同时执行
wait/sleep的区别:
来自不同的类:wait => Object
sleep => Thread
关于锁的释放:wait会释放锁,而sleep不会
java创建多线程的三种方式:继承Thread类、实现Runnable接口、匿名内部类
3.最常见的锁:synchronized(经典的可重入锁)
synchronized的三种应用方式:
- 普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁
- 静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁
- 同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
作用:Synchronized是Java中解决并发问题的一种最常用最简单的方法 ,他可以确保线程互斥的访问同步代码
//修饰方法
public synchronized void increase(){
i++;
}
//代码块
public class test{
public void test(){
synchronized(this){
}
}
}
1、 无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。
2、每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。
3、实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制
synchronized是会自己升级的:偏向锁 → 轻量级锁 → 重量级锁
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。
一个锁只能按照 偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有叫锁膨胀的),不允许降级。
经典面试题:手写一个单例模式
然后你心里想:简单,饿汉式、懒汉式都给你写一个
面试官:一般水平
那么如何展示我们的水平高呢,我们写一个双重校验锁的单例:
public class Singleton {
//采用volatile修饰
private volatile static Singleton singleton;
//构造方法私有化
private Singleton(){}
//双重校验锁
public static Singleton getInstance(){
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if(singleton == null){
//类对象加锁
synchronized(Singleton.class){
//再次判断
if (singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
使用volatile 的原因:避免jvm指令重排
因为 singleton = new Singleton() 这句话可以分为三步:
- 为 singleton 分配内存空间;
- 初始化 singleton;
- 将 singleton 指向分配的内存空间。
但是由于JVM具有指令重排的特性,执行顺序有可能变成 1-3-2。 指令重排在单线程下不会出现问题,但是在多线程下会导致一个线程获得一个未初始化的实例。例如:线程T1执行了1和3,此时T2调用 getInstance() 后发现 singleton 不为空,因此返回 singleton, 但是此时的 singleton 还没有被初始化。
使用 volatile 会禁止JVM指令重排,从而保证在多线程下也能正常执行
4.volatile关键字、内存可见性、原子性、有序性
Java 内存模型中的可见性、原子性和有序性。
内存可见性(Memory Visibility)
什么叫可见性?一个线程对共享变量值的修改,能够被其它线程看到
代码示例:
public class volatileTest {
public static void main(String[] args) {
ThreadDemo td = new ThreadDemo();
new Thread(td).start();
while(true){
if(td.isFlag()){
System.out.println("------------------");
break;
}
}
}
}
class ThreadDemo implements Runnable {
private volatile boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
flag = true;
System.out.println("flag=" + isFlag());
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
当我们去掉volatile关键字时:
我们可以发现,程序并未执行完成,说明了isFlag一直为false。
原因:
上面的程序中共有两个线程,一个是td ,一个是主线程,此时td线程修改了值,但是主线程就是没有获得到,这里可以根据上面的JMM得到答案,就是说此时有一块主存,线程td从主存中读取的flag=false,此时睡眠0.2秒,主线程从主存中也读取了flag=false。0.2秒过后,td线程中flag=true,但是main线程中while(true)执行的速度特别快,是计算机比较底层的代码,所以main线程一直都没有机会再次从主存中读取数据(此时他也不知道主存的数据被更改)。这两个线程之间操作共享数据彼此是不可见的。
volatile不具备原子性:
public class volatileTest2 {
public static void main(String[] args) {
myData myData=new myData();
for (int i = 0; i <20 ; i++) {
new Thread(()->{
for (int j = 0; j <1000 ; j++) {
myData.addPlusPlus();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount()>2){ //java最起码有一个main线程,和一个垃圾回收线程,所以这里是2
Thread.yield();//当前线程由执行态变为就绪态,让出cpu
}
System.out.println(myData.number);
}
}
class myData{
volatile int number=0;
public void addPlusPlus(){
this.number++;
}
}
我们运行三次看结果:
第一次:
第二次:
第三次:
这里我们得出一个结论 num++ 在多线程下是不安全的
因为num++ 实际上是分三步的,
第一步:栈中取出i
第二步:i自增1
第三步:将i存到栈
尽管用了volatile 第三步能够及时写入到内存。但是它不具备原子性,比如线程A从栈中取出i,此时完成了自增,发生了线程调度,此时线程B取出栈的值,尽管线程A里的值发生了更改,但是还未写到栈里,此时线程B操作的还是之前的值。这就证明了volatile不具备原子性。
如何具有原子性呢?
1、synchronized同步锁(不推荐、太重量级且效率低)
2、Atomic包(Compare And Swap(CAS))
public class TestVolatile1 {
public static void main(String[] args) {
myData myData=new myData();
for (int i = 0; i <20 ; i++) {
new Thread(()->{
for (int j = 0; j <1000 ; j++) {
myData.addAtomic();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount()>2){ //java最起码有一个main线程,和一个垃圾回收线程
Thread.yield();//当前线程由执行态变为就绪态,让出cpu
}
System.out.println(Thread.currentThread().getName()+"\t" +myData.atomicInteger);
}
}
class myData{
AtomicInteger atomicInteger=new AtomicInteger(); // 不用赋值,默认就是0
public void addAtomic(){
atomicInteger.getAndIncrement();// 表示i++
}
}
volatile禁止指令重排序
前提条件:计算机在执行程序的时候,为了提高性能,编译器和处理器会对指令进行重排序。
举例:
//线程一
a = 1;
flag = true;
//线程二
if(flag){
a = a + 5;
System.out.println(a);
}
此时线程如果线程1的执行顺序a=1,flag=true,则线程2输出的结果为6,如果此时线程1排序后,先执行了flag=true,还未执行a=1,那么此时恰巧线程2获取了flag=true,最终结果就是5了。解决这个问题 变量前用volatile关键字就可以解决。
重排序对单线程无影响,只影响多线程。因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
参考文章: