多线程(全)

多线程

进程

正在运行的程序。系统进行资源分配和调用的独立单位,每个进程都有它自己的内存空间和系统资源。

线程

在一个进程内又可以执行多个任务,每个任务看成线程。程序的执行单元,程序使用CPU的基本单位。依赖进程存在。

意义

  • 某一进程的执行路径(线程)越多,就有更高几率抢到CPU执行权
  • 提高应用程序使用率
  • 不敢保证那一个线程在什么时候抢到,线程具有随机性

并发与并行

并发:物理上同时发生,在某个时间段同时运行多个程序

并行: 逻辑上同时发生,在某个时间点内同时运行多个程序

JVM启动

jvm虚拟机启动时多线程: 主线程调用main方法、垃圾回收线程也要启动 最低这两个。

run()和start()区别

**run():**仅仅封装被线程执行的代码,直接调用是普通方法。

**start():**首先启动了线程,然后由JVM去调用该线程的run()方法。

实现多线程

方式一(继承Thread类)

  1. 自定义Mythread继承Thread类

  2. MyThread类里重写run()方法

    (不是类中所有代码都需要被线程执行,为了区分哪些代码被线程执行,用run()方法包含那些被线程执行的代码)

  3. 创建对象

  4. 调用线程对象的start()方法来启动该线程。

public class MyThreadDema {
	public static void main(String[] args) {
		MyThread my1 = new MyThread();
        MyThread my2 = new MyThread();
		my1.start();
        my2.start();
	}
}

public class MyThread extends Thread {
	@Override
	public void run() {
		for(int x=0;x<10;x++) {
            //public static String getName():获取线程名称
			System.out.println(getName()+"...."+x);
		}

	}
}

方式二(实现Runnable接口)

步骤:

1)自定义一个MyRunnable类,实现Runnable接口

2)重写run()方法

3)创建MyRunnable对象

4)创建Thread类对象,并把3步骤对象作为构造参数传递

public class MyRunnableDemo {
	public static void main(String[] args) {

		// 创建MyRunnable类对象
		MyRunnable myr = new MyRunnable();
		// 4)创建Thread类对象,并把3步骤对象作为构造参数传递
		
		//Thread(Runnable target,String name)
		Thread t1 = new Thread(myr,"叶凡");
		Thread t2 = new Thread(myr,"狠人大帝");

		//t1.setName("叶凡");
		//t2.setName("狠人大帝");

		t1.start();
		t2.start();

	}
}
public class MyRunnable implements Runnable {

	public void run() {
		for (int x =0; x < 10; x++) {
			//由于实现接口的方式不能直接使用Thread的方法,但可以间接使用
			System.out.println(Thread.currentThread().getName()+ ":"+x);

		}

	}
}

方式二的好处:

  • 避免单继承带来的局限性
  • 适合多个相同的程序去处理同一个资源,将线程同程序的代码,数据有效分离,较好地体现了面向对象思想。

方式三(线程池实现Runnable或者Callable)

不带参:

public class CallableDemo {
	public static void main(String[] args) {
		ExecutorService	pool= Executors.newFixedThreadPool(2);
		//可以执行Runnable对象或者Callable对象代表的线程
	    pool.submit(new MyCallable());
	    pool.submit(new MyCallable());
	    
	    //结束
	    pool.shutdown();
	}
}

//Callable:带泛型接口
//这里的泛型其实是call()方法的返回值
public class MyCallable implements Callable {

	@Override
	public  Object call() throws Exception {
		for (int x =0; x < 10; x++) {
			//由于实现接口的方式不能直接使用Thread的方法,但可以间接使用
			System.out.println(Thread.currentThread().getName()+ ":"+x);
		}
		return null;
	}

}

带参数:

public class CallableDemo {
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		ExecutorService pool = Executors.newFixedThreadPool(2);
		// 可以执行Runnable对象或者Callable对象代表的线程
		//Future接口表示异步计算的结果 
		Future<Integer> f1 = pool.submit(new MyCallable(100));
		Future<Integer> f2 = pool.submit(new MyCallable(200));
		// v get() 方法返回结果类型
		Integer i1 = f1.get();
		Integer i2 = f2.get();
		
		System.out.println(i1);
		System.out.println(i2);
		// 结束
		pool.shutdown();
	}
}
//Callable:带泛型接口
//这里的泛型其实是call()方法的返回值
public class MyCallable implements Callable<Integer> {
	private int number;

	public MyCallable(int number) {
		this.number = number;
	}

	public Integer call() throws Exception {
		int sum = 0;
		for (int x = 0; x < number; x++) {
			sum +=x;
			
		}
		return sum;
	}

}

线程调度

分时调度:轮流使用CPU使用权,平均分配时间片

抢占调度(java):优先让优先级高的使用CPU,越高时间片越多。优先级默认:5

线程优先级仅仅表示 几率高。

获取优先级

public final int getPriority()//返回线程对象优先级

更改优先级

public final void setPriority(int newpriority)//设置优先级 1-10
public class MyThreadDema {
	public static void main(String[] args) {
		MyThread my1 = new MyThread();
		MyThread my2 = new MyThread();
		MyThread my3= new MyThread();
		
		my1.setName("东方不败");
		my2.setName("荒天帝");
		my3.setName("狠人大帝");
		
		my1.setPriority(2);
		my2.setPriority(10);
		
		my1.start();
		my2.start();
		my3.start();
	}
}

public class MyThread extends Thread {
	
	@Override
	public void run() {
		for(int x=0;x<10;x++) {
			System.out.println(getName()+"....."+x);
		}

	}
}


线程控制

休眠线程

1、方法介绍

sleep(long millis)  线程睡眠 millis 毫秒

sleep(long millis, int nanos)  线程睡眠 millis 毫秒 + nanos 纳秒

2、如何调用sleep

因为sleep()是静态方法,所以最好的调用方法就是 Thread.sleep()。

3、在哪里写sleep更合理?

线程的sleep方法应该写在线程的run()方法里,就能让对应的线程睡眠。
public class MyThreadDema {
	public static void main(String[] args) {
		MyThread my1 = new MyThread();
		MyThread my2 = new MyThread();
		MyThread my3= new MyThread();
		
		my1.setName("东方不败");
		my2.setName("荒天帝");
		my3.setName("狠人大帝");
		
		my1.setPriority(2);
		my2.setPriority(10);
		
		my1.start();
		my2.start();
		my3.start();
	}
}


public class MyThread extends Thread {
	
	@Override
	public void run() {
		for(int x=0;x<10;x++) {
			System.out.println(getName()+"....."+x);
					
					try {
						Thread.sleep(1000);
					} catch (InterruptedException e) {
					
						e.printStackTrace();
					}
		}

	}
}

输出:

东方不败.....0
狠人大帝.....0
荒天帝.....0
-------------间隔1s
东方不败.....1
狠人大帝.....1
荒天帝.....1

加入线程

public final boid join()//等待线程终止

必须放在start()之后

格式

线程对象B.join() 无参数,则A线程一直暂停,直到B线程运行结束。

线程对象B.join(时间t) 有参数,则A线程每隔t时间暂停一次,直到B线程运行结束。

public class MyThreadDema {
	public static void main(String[] args) {
		MyThread my1 = new MyThread();
		MyThread my2 = new MyThread();
		MyThread my3= new MyThread();
		
		my1.setName("东方不败");
		my2.setName("荒天帝");
		my3.setName("狠人大帝");
		
		
		
		my1.start();
		try {
			my1.join();
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		
		my2.start();
		my3.start();
	}
}

public class MyThread extends Thread {
	
	@Override
	public void run() {
		for(int x=0;x<10;x++) {
			System.out.println(getName()+"....."+x);
					
		}

	}
}

输出结果

东方不败.....0
东方不败.....1
东方不败.....2
东方不败.....3
东方不败.....4
东方不败.....5
东方不败.....6
东方不败.....7
东方不败.....8
东方不败.....9
荒天帝.....0
狠人大帝.....0
荒天帝.....1
狠人大帝.....1
荒天帝.....2
狠人大帝.....2
狠人大帝.....3
狠人大帝.....4
狠人大帝.....5
狠人大帝.....6
狠人大帝.....7
狠人大帝.....8
狠人大帝.....9
荒天帝.....3
荒天帝.....4
荒天帝.....5
荒天帝.....6
荒天帝.....7
荒天帝.....8
荒天帝.....9

礼让线程

public static void yield():暂停当前正在执行的线程对象,并执行其他线程。 

​ Thread类中提供了一种礼让方法,使用yield()方法表示,它只是给当前正处于运行状态下的线程一个提醒,告知它可以将资源礼让给其他线程,但这仅仅是一种暗示,没有任何一种机制保证当前线程会将资源礼让。yield()方法使具有同样优先级的线程有进入可执行状态的机会,当当前线程放弃执行权时会再度回到就绪状态。

public class MyThreadDema {
	public static void main(String[] args) {
	
		MyThread my2 = new MyThread();
		MyThread my3= new MyThread();
		
		my2.setName("荒天帝");
		my3.setName("狠人大帝");
		
		my2.start();
		my3.start();
	}
}
public class MyThread extends Thread {
	
	@Override
	public void run() {
		for(int x=0;x<6;x++) {
			System.out.println(getName()+"....."+x);
					Thread.yield();
		}

	}
}

输出:

荒天帝.....0
狠人大帝.....0
荒天帝.....1
狠人大帝.....1
荒天帝.....2
狠人大帝.....2
荒天帝.....3
狠人大帝.....3
荒天帝.....4
狠人大帝.....4
荒天帝.....5
狠人大帝.....5

但是不能一定保证你一次,我一次

后台线程

public final void setDaemon(boolean on):将该线程标记为守护线程或用户线程

必须在启动线程前调用。

特征:

如果所有的前台线程都死亡,后台线程会自动死亡

ublic class MyThreadDema {
	public static void main(String[] args) {
	
		MyThread my2 = new MyThread();
		MyThread my3= new MyThread();
		
		my2.setName("荒天帝");
		my3.setName("狠人大帝");
		
		my2.setDaemon(true);
		my3.setDaemon(true);
		
		my2.start();
		my3.start();
		
		Thread.currentThread().setName("叶凡");
		for(int x=0;x<1;x++) {
			System.out.println(Thread.currentThread().getName()+"....."+x);
					
		}
	}
}

public class MyThread extends Thread {
	
	@Override
	public void run() {
		for(int x=0;x<99;x++) {
			System.out.println(getName()+"....."+x);
					Thread.yield();
		}

	}
}

输出

叶凡.....0
荒天帝.....0
狠人大帝.....0
荒天帝.....1
狠人大帝.....1
狠人大帝.....2
荒天帝.....2
狠人大帝.....3
荒天帝.....3
荒天帝.....4

当“叶凡”线程死去 另外两个线程会相继死去,不会立刻死,会延迟一会。

中断线程

public void interrupt();//中断线程状态,并抛出异常往下执行
public final void stop()//中断线程 ,过时
public class MyThreadDema {
	public static void main(String[] args) {
	
		MyThread my2 = new MyThread();
		
		my2.start();
		try {
			//超过3s 就终止
			Thread.sleep(3000);
			my2.interrupt();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	
	}
}
public class MyThread extends Thread {

	@Override
	public void run() {
		System.out.println("开始执行:" + new Date());
		// 休息10s
		try {
			Thread.sleep(10000);
		} catch (InterruptedException e) {
			System.out.println("线程被终止了");
			

		}
		System.out.println("结束执行:" + new Date());
	}
}

生命周期

线程的生命周期:

在这里插入图片描述

线程安全

线程安全的本质

​ 主内存中的变量是共享的,所有线程都可以访问读写,而线程工作内存又是线程私有的,线程间不可互相访问。那在多线程场景下,图上的线程 A 和线程 B 同时来操做共享内存里的同一个变量,那么主内存内的此变量数据就会被破坏。也就是说主内存内的此变量不是线程安全的。

img

public class ThreadDemo {
    private int x = 0;

    private void count() {
        x++;
    }

    public void runTest() {
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("final x from 1: " + x);
            }
        }.start();
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("final x from 2: " + x);
            }
        }.start();
    }

    public static void main(String[] args) {
        new ThreadDemo().runTest();
    }
}

示例代码中 runTest 方法2个线程分别执行 1_000_000count() 方法, count() 方法中只执行简单的 x++ 操作,理论上每次执行 runTest 方法应该有一个线程输出的 x 结果应该是2_000_000。但实际的运行结果并非我们所想:

final x from 1: 989840
final x from 2: 1872479

我运行了10次,其中一个线程输出 x 的值为 2_000_000 只出现了2次。

final x from 1: 1000000
final x from 2: 2000000

出现这样的结果的原因也就是我们上面所说的,在多线程环境下,我们主内存的 x 变量的数据被破坏了。 我们都知道完成一次 i++ 相当于执行了:

int tmp = x + 1;
x = tmp;

在多线程环境下就会出现在执行完 int tmp = x + 1; 这行代码时就发生了线程切换,当线程再次切回来的时候,x 就会被重复赋值,导致出现上面的运行结果,2个线程都无法输出 2_000_000

出现问题的原因

A:多线程环境

B:共享数据

C:多条语句操作共享数据

解决

线程同步

Java 提供了一系列的关键字和类来保证线程安全

Synchronized 关键字

​ Synchronized 保证⽅法内部或代码块内部资源(数据)的互斥访问。即同⼀时间、由同⼀个 Monitor(监视锁) 监视的代码,最多只能有⼀个线程在访问。

格式:
Synchronized (对象){
       代码块
}

注意:关键是对象(任意对象),多个线程必须是同一把锁。

锁的类型 & 等级
  • 由于Synchronized 会修饰 代码块、类的实例方法 & 静态方法,故分为不同锁的类型

  • 具体如下

  • img

  • 之间的区别

    img

synchronized原理

Synchronized的作用主要有三个:

  1. 确保线程互斥的访问同步代码
  2. 保证内存可见性
  3. 解决指令重排序问题

几种常见使用场景:

public class Test {

    private static int count = 10;

    private int i = 0;

    // 同步代码块: 锁定的是当前对象
    public void m1(){
        synchronized (this){
            i++;
            System.out.println("i = " + i);
        }
    }

    // 同步普通方法: 锁定的是当前对象
    public synchronized void m2(){
        i++;
        System.out.println("i = " + i);
    }

    // 同步静态方法: 锁定的是类对象,Test.class对象
    public synchronized static void m3(){
        count--;
        System.out.println(Thread.currentThread().getName() + "count = " + count);
    }
    
    // 同步代码块,主动锁定类对象
    public static void m4(){
        synchronized(Test.class){
            count--;
        }
    }
}

编译上面的Test.java 生成Test.class文件,找到该文件位置,打开命令行,使用如下命令进行反编译:

javap -verbose Test.class

方法m1反编译结果:

img

image.png

关于这两条指令的作用,我们直接参考JVM规范中描述:
monitorenter:

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

大概意思:
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

大概意思:
执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

同步方法m2的反编译结果:

img

image.png

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。
JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果没有设置则就是普通方法,就直接调用;如果设置了则是同步方法,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

其它几种都类似。

下面我们来继续分析,但是在深入之前我们需要了解两个重要的概念:Java对象头,Monitor。

Java对象头、Monitor

Java对象头和monitor是实现synchronized的基础!下面就这两个概念来做详细介绍。

Java对象头

synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述

Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

img

Mark Word

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):

img

image.png

Monitor

什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:

img

image.png

  • Owner: 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
  • EntryQ: 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
  • RcThis: 表示blocked或waiting在该monitor record上的所有线程的个数。
  • Nest: 用来实现重入锁的计数。
  • HashCode: 保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
  • Candidate: 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。
锁优化

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

先了解CPU的两种工作状态:内核态,用户态。
内核态: 系统中既有操作系统的程序,也有普通用户程序。为了安全性和稳定性,操作系统的程序不能随便访问,这就是内核态。即需要执行操作系统的程序就必须转换到内核态才能执行。内核态可以使用计算机所有的硬件资源。
用户态:不能直接使用系统资源,也不能改变CPU的工作状态,并且只能访问这个用户程序自己的存储空间。

自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。

自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;

如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:

    public void vectorTest(){
        Vector<String> vector = new Vector<String>();
        for(int i = 0 ; i < 10 ; i++){
            vector.add(i + "");
        }
        System.out.println(vector);
    }

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数的情况下,上述观点是正确的,但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

轻量级锁

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

获取锁:

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

释放锁:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;

img

轻量级锁的获取和释放过程

偏向锁

引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:

获取锁:

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块

释放锁:
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;

img

偏向锁的获取和释放流程

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

内存可见性

一个线程运行时有主内存(Java虚拟机内存,即堆,栈等)和工作区(线程专有的内存和CPU缓存等),线程首先会将数据从主内存读取copy到工作区,之后就一直读取工作区的数据,线程执行完后(不一定,有可能中途cpu空闲了也可能会写回或读取主内存)数据若有改变会写回到主内存。

CPU在执行代码的时候,为了减少变量访问的时间消耗可能将代码中访问的变量的值缓存到该CPU缓存区中,因此,相应的代码再次访问该变量的时候,相应的值可能从CPU缓存中而不是主内存中读取的。同样的,代码对这些被缓存过的变量的值的修改也可能仅是被写入CPU缓存区,而没有写入主内存。由于每个CPU都有自己的缓存区,因此一个CPU缓存区中的内容对于其他CPU而言是不可见的。这就导致了在其他CPU上运行的其他线程可能无法“看到”该线程对某个变量值的更改。这就是所谓的内存可见性。

synchronized关键字的另一个作用就是保证了一个线程执行临界区中的代码时,所修改的变量值对于稍后执行该临界区的线程来说是可见的。这对于保证多线程代码的正确性来说非常重要。
另一个关键字volatile,关键字作用是当线程在工作区修改了volatile修饰的数据后,会立即将该数据写回到主内存,并且通知其它线程重新从主内存读取该数据。简单说就是缓存过期通知。

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序会遵守数据的依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。重排序分为如下三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操 作看上去可能是在乱序执行。

第二条指令重排序的例子:

class Test {
    static int i = 0, j = 0;
    static void one() { i++; j++; }
    static void two() {
        System.out.println("i=" + i + " j=" + j);
    }
}

变量i 和j 没有数据依赖关系,所以可能会出现指令重排序的情况,当有两个线程同时不断的分别执行one()和two()方法,会出现有时j的值比i的值大的情况,说明j++有时在i++之前执行。
指令重排序是为了提高性能,但是有时候可能会出现问题。
synchronized和volatile都有禁止指令重排序的功能。
但当将以上的类修改为下面时:

class Test {
    static int i = 0, j = 0;
    static synchronized void one() { i++; j++; }
    static synchronized void two() {
        System.out.println("i=" + i + " j=" + j);
    }
}

也就是使用synchronized关键字修饰方法one和two后,则不会出现j大于i的情况,也正说明了synchronized具有禁止代码重排序功能。

Volatile
  • volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略。

  • 可以简单的理解为Java提供的一个比Synchronized更加轻量级的同步机制。但是大多数程序员都不能正确、完整的理解和使用它。一般在遇到多线程处理数据竞争时,一律使用Synchronized来进行同步。

    但是我们都知道Synchronized是一个重量级的锁,虽然jvm对其进行了大量的优化,但是在volatile相较于synchronized来说是一个轻量级的锁。如果用它来修饰一个变量,那么会比使用synchronized成本更加的低,因为volatile不会引起线程的上下文切换和调度。并且Java语言规范对volatile有如下定义:

    Java语言是允许线程访问共享变量的,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独地获取该变量。Java语言提供了Volatile,在某些情况下比锁妖更加地方便。如果一个字段被声明成volatileJava线程内存模型确保所有线程都可以看到该变量地值,并且保持一致性。

    其实就是Java就是提供了volatile来修饰变量,来确保该变量在线程中是可见的。保证了其原子性。(那么在什么时候该使用volatile呢?有这么简便轻便的锁为什么还存在并且使用synchronized这种相对而言非常笨重的锁呢?)


    那么在了解volatile实现原理之前,首先要了解其实现原理相关地CPU术语与说明。

    术语英文单词术语描述
    内存屏障memory barriers是一组处理器指令,用于实现对内存操作地顺序限制
    缓冲行cache line缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期
    原子操作atomic operations不可中断的一个或一系列操作
    缓存行填充cache line fill当处理器识别到从内存中读取操作时可缓存的,处理器读取整个缓存到适当的缓存(L1、L2、L3的或所有)
    缓存命中cache hit如果进行告诉缓存行填充操作的内存仍然时下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取
    写命中write hit当处理器及那个操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存中,而不是写回到内存,这个操作被称为写命中
    写缺失write misses th cache一个有效的缓存行被写入到不存在的内存区域
2.1 volatile 的特性
  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
  • 禁止进行指令重排序。(实现有序性)
  • volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。
2.2 volatile 的实现原理
2.2.1 volatile 可见性实现
  • volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。
    • 内存屏障,又称内存栅栏,是一个 CPU 指令。
    • 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
  • 写一段简单的 Java 代码,声明一个 volatile 变量,并赋值。
public class Test {
    private volatile int a;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
        Test test = new Test();
        test.update();
    }
}
  • 通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码。
......
  0x0000000002951563: and    $0xffffffffffffff87,%rdi
  0x0000000002951567: je     0x00000000029515f8
  0x000000000295156d: test   $0x7,%rdi
  0x0000000002951574: jne    0x00000000029515bd
  0x0000000002951576: test   $0x300,%rdi
  0x000000000295157d: jne    0x000000000295159c
  0x000000000295157f: and    $0x37f,%rax
  0x0000000002951586: mov    %rax,%rdi
  0x0000000002951589: or     %r15,%rdi
  0x000000000295158c: lock cmpxchg %rdi,(%rdx)  //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
  0x0000000002951591: jne    0x0000000002951a15
  0x0000000002951597: jmpq   0x00000000029515f8
  0x000000000295159c: mov    0x8(%rdx),%edi
  0x000000000295159f: shl    $0x3,%rdi
  0x00000000029515a3: mov    0xa8(%rdi),%rdi
  0x00000000029515aa: or     %r15,%rdi
......
  • lock 前缀的指令在多核处理器下会引发两件事情。
    • 1)将当前处理器缓存行的数据写回到系统内存。
    • 2)写回内存的操作会使在其他 CPU 里缓存了该内存地址的额数据无效。
  • 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。
  • 如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
  • 为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
    • 所有多核处理器下还会完成:3)当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
  • volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
2.2.1.1 lock 指令
  • 在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。
  • 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。
    • 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。
    • 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。
2.2.1.2 缓存一致性
  • 缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。
  • LOCK# 因为锁总线效率太低,因此使用了多组缓存。
    • 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议
    • 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。
  • 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。
  • 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。
  • CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。
  • 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。
    • 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
2.2.2 volatile 有序性实现
2.2.2.1 volatile 的 happens-before 关系
  • happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
//假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    
    public void writer() {
        a = 1;              // 1 线程A修改共享变量
        flag = true;        // 2 线程A写volatile变量
    } 
    
    public void reader() {
        if (flag) {         // 3 线程B读同一个volatile变量
        int i = a;          // 4 线程B读共享变量
        ……
        }
    }
}
  • 根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。
    • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
    • 根据 volatile 规则:2 happens-before 3。
    • 根据 happens-before 的传递性规则:1 happens-before 4。

img

VolatileExample

  • 因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。
2.2.2.2 volatile 禁止重排序
  • 为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。
  • Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
  • JMM 会针对编译器制定 volatile 重排序规则表。

img

volatile 重排序规则表

  • " NO " 表示禁止重排序。
    • 为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
    • 对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
      • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
      • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
      • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
      • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
  • volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
内存屏障说明
StoreStore 屏障禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障禁止下面所有的普通写操作和上面的 volatile 读重排序。

img

volatile 写插入内存屏障

img

volatile 读插入内存屏障

2.3 volatile 的应用场景
  • 使用 volatile 必须具备的条件
    • 对变量的写操作不依赖于当前值。
    • 该变量没有包含在具有其他变量的不变式中。
  • 只有在状态真正独立于程序内其他内容时才能使用 volatile。
  • 模式 #1 状态标志
    • 也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}
  • 模式 #2 一次性安全发布(one-time safe publication)
    • 缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
public class BackgroundFloobleLoader {
    public volatile Flooble theFlooble;
 
    public void initInBackground() {
        // do lots of stuff
        theFlooble = new Flooble();  // this is the only write to theFlooble
    }
}
 
public class SomeOtherClass {
    public void doWork() {
        while (true) { 
            // do some stuff...
            // use the Flooble, but only if it is ready
            if (floobleLoader.theFlooble != null) 
                doSomething(floobleLoader.theFlooble);
        }
    }
}
  • 模式 #3:独立观察(independent observation)
    • 安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
public class UserManager {
    public volatile String lastUser;
 
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}
  • 模式 #4 volatile bean 模式
    • 在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。
@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
 
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
 
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
 
    public void setAge(int age) { 
        this.age = age;
    }
}
  • 模式 #5 开销较低的读-写锁策略
    • volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。
    • 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。
    • 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    public int getValue() { return value; }
 
    public synchronized int increment() {
        return value++;
    }
}
  • 模式 #6 双重检查(double-checked)
    • 单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。
class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            syschronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}
  • 推荐懒加载优雅写法 Initialization on Demand Holder(IODH)。
public class Singleton {  
    static class SingletonHolder {  
        static Singleton instance = new Singleton();  
    }  
      
    public static Singleton getInstance(){  
        return SingletonHolder.instance;  
    }  
}
总结:

volatile看起来非常的简单,并且轻巧。但是也存在很多弊端。相较于synchronized来说,在某些场景可以代替synchronized,但又不能完全取代。因为在使用volatile时,大部分只能修饰关键变量,并且如果大量使用volatile反而会影响运行效率。因为volatile会禁止指令重排序,禁用CPU优化。在使用它必须满足如下两个条件

  1. 对变量的写操作不依赖当前值;
  2. 该变量没有包含在具有其他变量的不变式中;

volatile经常用于两个场景:状态标记量、double check

synchronized 和 volatile 的区别是什么?
  • volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
Lock

img

lock: 在java.util.concurrent包内。共有三个实现:

  • ReentrantLock
  • ReentrantReadWriteLock.ReadLock
  • ReentrantReadWriteLock.WriteLock

主要目的是和synchronized一样, 两者都是为了解决同步问题,处理资源争端而产生的技术。功能类似但有一些区别。

区别如下:

  1. lock更灵活,可以自由定义多把锁的枷锁解锁顺序(synchronized要按照先加的后解顺序)
  2. 提供多种加锁方案,lock 阻塞式, trylock 无阻塞式, lockInterruptily 可打断式, 还有trylock的带超时时间版本。
  3. 本质上和监视器锁(即synchronized是一样的)
  4. 能力越大,责任越大,必须控制好加锁和解锁,否则会导致灾难。
  5. 和Condition类的结合。
  6. 性能更高,对比如下图:

img

synchronized和Lock性能对比

ReentrantLock    
可重入的意义在于持有锁的线程可以继续持有,并且要释放对等的次数后才真正释放该锁。
使用方法是:

1.先new一个实例

static ReentrantLock r=new ReentrantLock();

2.加锁

r.lock()或r.lockInterruptibly();

此处也是个不同,后者可被打断。当a线程lock后,b线程阻塞,此时如果是lockInterruptibly,那么在调用b.interrupt()之后,b线程退出阻塞,并放弃对资源的争抢,进入catch块。(如果使用后者,必须throw interruptable exception 或catch)

3.释放锁

r.unlock()

必须做!何为必须做呢,要放在finally里面。以防止异常跳出了正常流程,导致灾难。这里补充一个小知识点,finally是可以信任的:经过测试,哪怕是发生了OutofMemoryError,finally块中的语句执行也能够得到保证。

ReentrantReadWriteLock

可重入读写锁(读写锁的一个实现)

  ReentrantReadWriteLock lock = new ReentrantReadWriteLock()
  ReadLock r = lock.readLock();
  WriteLock w = lock.writeLock();

两者都有lock,unlock方法。写写,写读互斥;读读不互斥。可以实现并发读的高效线程安全代码

Lock与synchronized区别

img

CAS
定义

Compare And Swap,即 比较 并 交换,是一种解决并发操作的乐观锁

synchronized锁住的代码块:同一时刻只能由一个线程访问,属于悲观锁

原理
// CAS的操作参数
内存位置(A)
预期原值(B)
预期新值(C)

// 使用CAS解决并发的原理:
// 1. 首先比较A、B,若相等,则更新A中的值为C、返回True;若不相等,则返回false;
// 2. 通过死循环,以不断尝试尝试更新的方式实现并发

// 伪代码如下
public boolean compareAndSwap(long memoryA, int oldB, int newC){
    if(memoryA.get() == oldB){
        memoryA.set(newC);
        return true;
    }
    return false;
}

优点

资源耗费少:相对于synchronized,省去了挂起线程、恢复线程的开销

但,若迟迟得不到更新,死循环对CPU资源也是一种浪费

具体实现方式
  • 使用CAS有个“先检查后执行”的操作
  • 而这种操作在Java中是典型的不安全的操作,所以 CAS在实际中是由C++通过调用CPU指令实现的
  • 具体过程
// 1. CAS在Java中的体现为Unsafe类
// 2. Unsafe类会通过C++直接获取到属性的内存地址
// 3. 接下来CAS由C++的Atomic::cmpxchg系列方法实现

典型应用:AtomicInteger

对 i++ 与 i–,通过compareAndSet & 一个死循环实现

compareAndSet函数内部 = 通过jni操作CAS指令。直到CAS操作成功跳出循环

   private volatile int value; 
    /** 
     * Gets the current value. 
     * 
     * @return the current value 
     */ 
    public final int get() { 
        return value; 
    } 
    /** 
     * Atomically increments by one the current value. 
     * 
     * @return the previous value 
     */ 
    public final int getAndIncrement() { 
        for (;;) { 
            int current = get(); 
            int next = current + 1; 
            if (compareAndSet(current, next)) 
                return current; 
        } 
    } 
 
    /** 
     * Atomically decrements by one the current value. 
     * 
     * @return the previous value 
     */ 
    public final int getAndDecrement() { 
        for (;;) { 
            int current = get(); 
            int next = current - 1; 
            if (compareAndSet(current, next)) 
                return current; 
        } 
    }

死锁

一、死锁的定义

多线程以及多进程改善了系统资源的利用率并提高了系统 的处理能力。然而,并发执行也带来了新的问题——死锁。所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。

下面我们通过一些实例来说明死锁现象。

先看生活中的一个实例,2个人一起吃饭但是只有一双筷子,2人轮流吃(同时拥有2只筷子才能吃)。某一个时候,一个拿了左筷子,一人拿了右筷子,2个人都同时占用一个资源,等待另一个资源,这个时候甲在等待乙吃完并释放它占有的筷子,同理,乙也在等待甲吃完并释放它占有的筷子,这样就陷入了一个死循环,谁也无法继续吃饭。。。
在计算机系统中也存在类似的情况。例如,某计算机系统中只有一台打印机和一台输入 设备,进程P1正占用输入设备,同时又提出使用打印机的请求,但此时打印机正被进程P2 所占用,而P2在未释放打印机之前,又提出请求使用正被P1占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程陷入死锁状态。

二、死锁产生的原因
1) 系统资源的竞争

通常系统中拥有的不可剥夺资源,其数量不足以满足多个进程运行的需要,使得进程在 运行过程中,会因争夺资源而陷入僵局,如磁带机、打印机等。只有对不可剥夺资源的竞争 才可能产生死锁,对可剥夺资源的竞争是不会引起死锁的。
2) 进程推进顺序非法
进程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发进程 P1、P2分别保持了资源R1、R2,而进程P1申请资源R2,进程P2申请资源R1时,两者都 会因为所需资源被占用而阻塞。

信号量使用不当也会造成死锁。进程间彼此相互等待对方发来的消息,结果也会使得这 些进程间无法继续向前推进。例如,进程A等待进程B发的消息,进程B又在等待进程A 发的消息,可以看出进程A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁。

3) 死锁产生的必要条件

产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。
**1、 互斥条件:**进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
2、不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
3、请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
**4、循环等待条件:**存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有,如图2-15所示。

直观上看,循环等待条件似乎和死锁的定义一样,其实不然。按死锁定义构成等待环所 要求的条件更严,它要求Pi等待的资源必须由P(i+1)来满足,而循环等待条件则无此限制。 例如,系统中有两台输出设备,P0占有一台,PK占有另一台,且K不属于集合{0, 1, …, n}。

Pn等待一台输出设备,它可以从P0获得,也可能从PK获得。因此,虽然Pn、P0和其他 一些进程形成了循环等待圈,但PK不在圈内,若PK释放了输出设备,则可打破循环等待, 如图2-16所示。因此循环等待只是死锁的必要条件。

img

资源分配图含圈而系统又不一定有死锁的原因是同类资源数大于1。但若系统中每类资 源都只有一个资源,则资源分配图含圈就变成了系统出现死锁的充分必要条件。

三、如何避免死锁

在有些情况下死锁是可以避免的。三种用于避免死锁的技术:

  1. 加锁顺序(线程按照一定的顺序加锁)
  2. 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
  3. 死锁检测

加锁顺序
当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。

如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。看下面这个例子:

Thread 1:
  lock A 
  lock B

Thread 2:
   wait for A
   lock C (when A locked)

Thread 3:
   wait for A
   wait for B
   wait for C

如果一个线程(比如线程3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。

例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(译者注:获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。

按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(译者注:并对这些锁做适当的排序),但总有些时候是无法预知的。

加锁时限
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(译者注:加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。

以下是一个例子,展示了两个线程以不同的顺序尝试获取相同的两个锁,在发生超时后回退并重试的场景:

Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

在上面的例子中,线程2比线程1早200毫秒进行重试加锁,因此它可以先成功地获取到两个锁。这时,线程1尝试获取锁A并且处于等待状态。当线程2结束时,线程1也可以顺利的获得这两个锁(除非线程2或者其它线程在线程1成功获得两个锁之前又获得其中的一些锁)。

需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。

此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为0到500毫秒之间,这种现象可能不会发生,但是如果是10个或20个线程情况就不同了。因为这些线程等待相等的重试时间的概率就高的多(或者非常接近以至于会出现问题)。
(译者注:超时和重试机制是为了避免在同一时间出现的竞争,但是当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。)

这种机制存在一个问题,在Java中不能对synchronized同步块设置超时时间。你需要创建一个自定义锁,或使用Java5中java.util.concurrent包下的工具。写一个自定义锁类不复杂,但超出了本文的内容。后续的Java并发系列会涵盖自定义锁的内容。

死锁检测
死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。

每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。

当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。

当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂的多。线程A等待线程B,线程B等待线程C,线程C等待线程D,线程D又在等待线程A。线程A为了检测死锁,它需要递进地检测所有被B请求的锁。从线程B所请求的锁开始,线程A找到了线程C,然后又找到了线程D,发现线程D请求的锁被线程A自己持有着。这是它就知道发生了死锁。

下面是一幅关于四个线程(A,B,C和D)之间锁占有和请求的关系图。像这样的数据结构就可以被用来检测死锁。
img

那么当检测出死锁时,这些线程该做些什么呢?

一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(编者注:原因同超时类似,不能从根本上减轻竞争)。

一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。

等待唤醒

在这里插入图片描述

进入到TimeWaiting(计时等待)有两种方式

 (1)使用sleep(long m)
 在毫秒值结束之后,线程睡醒进入到Runnable/Block状态,不释放锁
 
 (2)使用wait(long m)
 wait方法如果在毫秒值结束之后,还没被notify唤醒,就会自动醒来,线程睡醒进入到Runnable/Block状态,释放锁。

唤醒的方法:

void notify()      唤醒在此对象监视器上等待的单个线程。
void notifyAll()   唤醒在此对西那个监视器上等待的所有线程。

状态转换图

在这里插入图片描述

线程组

多线程中,为了方便管理一批线程,我们使用ThreadGroup来表示线程组,通过它对一批线程进行分类管理 。(默认情况下,A线程创建的线程B,B线程是属于A线程的线程组的。)


Thread类提供几个构造器来创建新的线程属于哪个线程组:

Thread(ThreadGroup group,Runnable target):以target的run()方法为线程执行提创建新线程,新线程属于线程组group。

Thread(ThreadGroup group,Runnable target,String name):以target的run()方法为线程执行提创建新线程,并且输入group线程组,新线程名字为name。

Thread(ThreadGroup,String name):创建新线程,属于group线程组,新线程名字为name。

ThreadGroup类的简单构造器以及几个常用方法:

ThreadGroup(String name):创建名为name的新线程组。

ThreadGroup(ThreadGroup parent,String name):创建指定父线程组,名为name的新线程组。

int activeCount():返回此线程组中活动线程的数目。

interrupt():中断此线程组中所有的线程。

isDaemon():判断此线程组是否为后台线程。

setDaemon(boolean daemon):设置线程组为后台线程(当后台线程组中最后一个线程执行完成活着最后一个线程被销毁,后台线程将自动销毁)。

setMaxPriority(int prio):设置线程组的最高优先级。


我们来用简单的代码例子说明下:

线程测试类:

img

测试线程组:

img

运行结果:

img

注意:不要把后台线程组和后台线程(守护线程)混为一谈,后台线程组的特性是最后一个线程执行完或最后一个线程被销毁时,后台线程组自动销毁,线程组只是为了统一管理线程的一个方式!

线程池

由来

程序启动一个新线程成本比较高,因为涉及到操作系统进行交互。

特点

线程池里每一个线程代码结束后,并不会死亡,而是再次回收到线程池称为空闲状态。

Jdk5新增了一个Executors工厂类来产生线程池,有如下方法

public static ExecutorsService newCachedThreadPool()
public static ExecutorsService newFixedThreadPool(int nThreads)
public static ExecutorsService newSingleThreadExecutor()

这些方法返回值是ExecutorsService对象,该对象表示一个线程池,可以执行Runnable对象或者Callable对象代表的线程。

调用如下方法: Future<?> submit(Runnable task)

​ Future submit(Callable task)

public class EcecutorsDemo {
	public static void main(String[] args) {
		//创建线程池对象,控制要创建几个线程对象
	ExecutorService	pool= Executors.newFixedThreadPool(2);
	//可以执行Runnable对象或者Callable对象代表的线程
    pool.submit(new MyRunnable());
    pool.submit(new MyRunnable());
    
     //结束线程
    pool.shutdown();
	}
}
public class MyRunnable implements Runnable {

	public void run() {
		for (int x =0; x < 10; x++) {
			//由于实现接口的方式不能直接使用Thread的方法,但可以间接使用
			System.out.println(Thread.currentThread().getName()+ ":"+x);
		}
	}
}

匿名内部类改进

public class ThreadDemo {

	public static void main(String[] args) {
		// 继承Thread
		new Thread() {
			public void run() {
				for (int x = 0; x < 100; x++) {
					System.out.println(Thread.currentThread().getName() + ":" + x);
				}
			}
		}.start();

		// 实现Runnable接口
		new Thread(new Runnable() {
			public void run() {
				for (int x = 0; x < 100; x++) {
					System.out.println(Thread.currentThread().getName() + ":" + x);
				}
			}
		}) {
		}.start();
	}
}

sleep()和wait()区别

  1. sleep:必须指定时间自动唤醒;wait:可以指定也可以不指定时间,不指定时间必须使用notify/notifyAll进行手动唤醒
  2. sleep:不释放锁;wait:释放锁
  3. sleep是在Thread类定义的,wait是在Object类定义的
  4. sleep一般不会产生死锁,但是wait可能会产生死锁
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值