JAVA并发编程-Java内置锁(Java高并发核心编程读书笔记)

122 阅读23分钟

Java内置锁是一个互斥锁, 意味着同一时刻, 某一把锁最多只能被一个线程占用. java中每个对象都可以用作锁, 这些锁被称为内置锁.

线程安全问题

在多个线程并发访问某个对象, 在多个线程交替操作下, 对象能否表现出一致的正确的表现行为. 就是线程安全需要考虑的范畴.

自增运算不是线程安全的

public class SelfAddNotSafe {
	public static void main(String[] args) throws InterruptedException {
		ExecutorService threadPool = Executors.newFixedThreadPool(3);
		CountDownLatch countDownLatch = new CountDownLatch(100);
		Demo demo = new Demo();
		for (int i=0; i<100; i++){
			threadPool.submit(() -> {
				for(int j=0;j<3;j++){
					demo.add();
				}
				countDownLatch.countDown();
			});
		}

		countDownLatch.await();
		threadPool.shutdown();

		demo.print();

	}

}
class Demo{
	private int i = 0;
	public void add() {
		 i++;
	}
	public void print() {
		System.out.println(i);
	}
}
## 输出
296

理论输出结果应该是300, 这里会出现不到300的情况, 是因为自增运算不是一个线程安全的操作, 其实现是个复合操作, 在内存里一次自增操作需要拆分为3步,

  1. 读取值
  2. 运算值
  3. 保存值

临界区资源和临界区代码块

在上面的示例中, 对共享对象demo的操作时, 其操作影响到的资源就是对象中的变量i, i就是这里说的临界区资源, i++这个操作就是临界区代码块.

由于不同的访问顺序导致产生了不同的结果. 这种现象称为: 竞态条件问题.

为了保证临界区资源的安全问题, 就需要对临界区的资源操作具有排他性, 也就是同一时刻只能允许有一个线程对临界区资源操作, 其他线程需要等待当前线程执行结束.

在Java中使用Synchronized关键字, Lock显示锁, 或者原子变量Atomic Variables可以实现对临界区资源的保护.

synchronized关键字

在Java中每个对象都有一把内置锁, 使用synchronized关键字, 就相当于调用对象的内置锁来对临界区的代码实现保护.

三种使用方法:

同步方法

使用代码块修饰的方法被称为同步方法, 使用最开始的自增示例修饰add方法:

public synchronized void synchronizedAdd() {
		i++;
	}

这时再来运行自增运算, 无论如何结果都会一直是300.

原因是通过synchronized修饰后, 该方法就变成了一个同步方法, 任何时间同一时刻, 只会有一个线程能够进入方法执行运算逻辑. 其他线程只能等待里面的线程退出后再执行.

同步代码块

同步代码块的原理和同步方法是一样的, 只是使用同步代码块能够缩小临界区, 因为直接在方法上使用synchronized关键字导致整个方法都是同步的, 严重的阻塞了代码运行效率, 所以使用同步代码块可以缩小临界区. 示例

public  void synchronizedBlockAdd() {
		synchronized (this) {
			i++;
		}
		// 其他操作
	}

这里临界区只有i++这一行代码, 后面的其他操作还是可以允许多个线程并行运行的.

在方法上使用synchronized关键字, 其使用的内置锁对象就是当前对象 demo, 和代码块这里使用的this关键字是同一个对象.

术语: 并发控制粒度, 在方法上使用比代码块使用粒度更粗. 代码块更细.

静态同步方法

Java有两种对象类型: Object实例对象, 例如前面所说的demo对象. 还有一个是Class对象, 例如前面定义的Demo.class. 每个类的运行时类型信息使用Class 对象表示, 包含类名称, 继承关系, 字段, 方法等. JVM在加载一个类到方法区时, 会为其创建一个Class对象, 对于一个类来说, 其Class对象时唯一的.

Class对象由JVM加载类时, 通过类加载器中的defineClass方法构建, 在代码中不能显示的声明一个Class对象.

在实例方法上使用synchronized关键字, 其同步锁是当前对象this的监视锁. 如果某个方法是静态方法, 它的同步锁是当前类的Class对象. 因为静态方法不属于实例对象, 属于Class实例. 在静态方法内部无法使用实例方法, 无法访问实例的this引用(指针, 句柄). 所以静态方法使用的同步锁是Class对象监视锁.

统称: 实例对象锁称为对象锁, Class对象锁称为: 类锁.

生产者消费者问题

生产者消费者模式中有三个重要主要成员: 生产者, 消费者, 数据缓冲区, 生产者负责生产数据并放入到缓冲区中, 消费者负责从缓冲区中获取数据消费. 缓冲区负责在生产者和消费之间转存数据.

为了提高效率, 生产者和消费往往都是不同的线程在执行, 正产的运行状态应该是生产者生产数据放入缓冲区, 在缓冲区没满的时候正常存放, 在缓冲区满的时候能够阻塞生产者继续存放数据, 消费者在缓冲区中没有数据的时候也能阻塞等待缓冲区中有新的数据.

其工作模式大致如图:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/04283359dc0c4dd89d8a5cb55213e286~tplv-k3u1fbpfcp-zoom-1.image

非线程安全的生产者消费者版本

数据缓冲区

public class DataBuffer<T> {
    private List<T> cache = new LinkedList<>();
    private int limit = 10;
    private int size = 0;

    public boolean put(T t) {
        if (size < limit) {
            cache.add(t);
            size++;
            if (size != cache.size()) {
                throw new RuntimeException("系统异常");
            }
        }

        return true;
    }

    public T fetch() {
        if (cache.size() > 0 && cache.size() == size) {
            cache.remove(0);
            size--;
            if (cache.size() != size) {
                throw new RuntimeException("系统异常");
            }
            if (cache.size() == 0) {
                return null;
            }
        }
        return null;
    }
}

生产者

public class Producer<T> implements Runnable {
    public static final int producer_gap = 200;
    public static AtomicInteger TURN = new AtomicInteger(0);
    public static final AtomicInteger producer_no = new AtomicInteger(0);
    String name = null;
    Callable<T> action = null;
    int gap = producer_gap;

    public Producer(Callable<T> action, int gap) {
        this.action = action;
        this.gap = gap;
        this.name = "生产者-" + producer_no.incrementAndGet();
    }
    @Override
    public void run() {
        while (true) {
            try {
                Object result = action.call();
                if (null != result) {
                    System.out.println("第" + TURN.get() + "轮生产: " + result);
                }
                Thread.sleep(gap);
                TURN.incrementAndGet();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

消费者

public class Consumer<T> implements Runnable {
    public static final int CONSUME_GAP = 100;
    static final AtomicInteger TURN = new AtomicInteger(0);
    static final AtomicInteger CONSUMER_NO = new AtomicInteger(0);
    String name = "";
    Callable<T> action = null;
    int gap = CONSUME_GAP;

    public Consumer(Callable<T> action, int gap) {
        this.action = action;
        this.gap = gap;
        name = "消费者-" + CONSUMER_NO.incrementAndGet();
    }
    @Override
    public void run() {
        while (true) {
            TURN.incrementAndGet();
            try {
                Object result = action.call();
                if (null != result) {
                    System.out.println("第" + TURN.get() + "轮消费: " + result);
                }
                Thread.sleep(gap);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

    }
}

测试代码

public class NotSafePCTest {

    private static DataBuffer<String> dataBuffer = new DataBuffer<>();
    public static void main(String[] args) {

        ExecutorService executors = Executors.newFixedThreadPool(20);
        executors.submit(new Producer<>(() -> {
            dataBuffer.put("a");
            return "a";
        }, 200));

        executors.submit(new Consumer<>(() -> {
            dataBuffer.fetch();
            return "a";
        }, 200));

        executors.shutdown();
    }
}

输出日志:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e570825312b340779cf07d749704a1c0~tplv-k3u1fbpfcp-zoom-1.image

报空指针的位置为if判断内:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b1b4912361ab4a10a86979f72c244fe4~tplv-k3u1fbpfcp-zoom-1.image

正产来说能执行到if条件内, 队列元素肯定不为空, 但是这么报了空指针是因为元素被其他线程移除了. 通过synchronized来解决上面的问题

修改代码为线程安全版本

public class DataBuffer<T> {
    private List<T> cache = new LinkedList<>();
    private int limit = 10;
    private int size = 0;

    public **synchronized** boolean put(T t) {
        if (size < limit) {
            cache.add(t);
            size++;
            if (size != cache.size()) {
                throw new RuntimeException("系统异常");
            }
        }

        return true;
    }

    public **synchronized** T fetch() {
        if (cache.size() > 0 && cache.size() == size) {
            cache.remove(0);
            size--;
            if (cache.size() != size) {
                throw new RuntimeException("系统异常");
            }
            if (cache.size() == 0) {
                return null;
            }
        }
        return null;
    }
}

输出:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/eb7ff468a9774e3f81f60bcde3e3cb43~tplv-k3u1fbpfcp-zoom-1.image

看起来一切正常, 问题是这样带来的后果是生产者和消费者的执行成了串行执行顺序, 即同一时刻只能有一个线程能够执行put或者fetch方法, 对效率的阻塞非常严重.

高效率的生产者-消费者模式肯定不能像这样工作. 必须要让消费者和生产者能够并行的执行. 等待后续解决.

Java对象结构和内置锁

Java内置锁很多重要信息都存放在对象结构中. 需要好好掌握下对象结构.

Java对象结构

三个部分, 对象头, 对象体, 对齐字节

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/86a3ec6a901644f1829eb37156437e3d~tplv-k3u1fbpfcp-zoom-1.image

  1. 对象头

对象头包含三个部分内容:

Mark Word 标记字, 用于存储运行时数据, GC标志位, 哈希码, 锁状态等信息.

Class Pointer 用于存放方法区的Class对象的地址, 通过这个地址虚拟机来确定对象属于哪个类的实例.

Array Length: 如果对象是一个数组, 次字段才有用, 用于记录数组的长度, 如果对象不是数组, 那么这个字段不存在.

  1. 对象体

包含对象的实例变量, 成员属性, 父类的成员属性. 按照4字节对齐.

  1. 对齐字节

为了保证每个对象的多占的内存字节数为8的整数倍HotSpot VM内存管理对象的要求对象的起始地址必须为8自己的倍数, 如果不足时通过对齐字段补齐.

核心字段的作用

MarkWord: 标识对象的线程锁状态, 还可以用来配合GC存放对象的hashCode.

Class Pointer: 指向方法区的Class对象的指针, 用来判断当前对象属于哪个类的实例.

ArrayLength: 记录数组对象的数组长度.

对象体: 保存成员属性.父成员属性.

对齐字节: 用于确保对象所占内存字节数为8的整数倍, 虚拟机的要求.

对象结构中的字段长度

JVM位数不同, 对象中的字段长度也不一致,

mark word 为一个字的大小, 即系统位数长度32/64

class pointer 也是一个字的大小, 可以通过开启压缩指针 +UseCompressedOops , 可以把64位的VM中对象指针长度修改为32位, 节省大量的内存空间, 包含: Class对象的属性指针, Object对象属性指针, 普通对象数组的元素指针.

特殊类型的不会压缩: 指向永久代的PermGen的Class对象指针, JDK8中的元空间, 本地变量, 堆栈元素, 入参, 返回值,null指针等.

在堆内存小于32GB时, 压缩指针默认开启.

手动开启: +UseCompressedOops mainclass 手动关闭: -UseCompressedOops mainclass

Mark Word结构信息

Java内置锁涉及很多重要信息都是保存在对象的对象头中, 对象头的长度不受压缩指针的影响.

Java内置锁有四种状态: 无锁, 偏向锁, 轻量级锁, 重量级锁.

64位jvm的状况:

状态biased_locklock
无锁001
偏向锁101
轻量级锁010
重量级锁011

lock: 锁状态标志位: 占2进制位

biased_lock: 偏向锁标志位 为1时表示启用偏向锁.

两个字段合起来能够表示一个对象处于什么样的锁状态.

age: 4位对象分代年龄. 默认情况下GC的年龄阈值为: 15, 因为长度4位最大值只能是15. 所以 -XX:MaxTenuringThreshold最大值15.

identify_hashcode: 31位对象标识HashCode采用延迟加载技术, 当调用对象的Object.hashCode()方法计算对象hashCode后, 其结果将保存在对象头中, 当对象被锁定时, 该值会被移动到Monitor中.

thread: 54位线程id为持有偏向锁的线程id.

epoch: 偏向时间戳.

prt_to_lock_record: 62位, 在轻量级锁状态下指向栈帧中锁记录的指针.

prt_to_heavyweight_monitor: 62位, 在重量级锁状态下指向对象监视器的指针.

使用JOL工具查看对象布局

OpenJDK提供的JOL工具, Java Object layout 可以在运行时计算某个对象的大小.

用来分析jvm中的对象的结构布局工具, 使用了大量的Unsafe, JVMTI来解码内部布局情况. maven坐标:

<dependency>
	<groupId>org.openjdk.jol</groupId>
	<artifactId>jol-core</artifactId>
	<version>0.11</version>
</dependency>

大小端问题

PowerPC系列CPU大端模式, X86系列CPU小端模式

大端模式: 数据的高字节保存在内存的地地址中. 类似于把数据当字符串顺序处理. 地址由小向大增加, 数据从从高位往低位放.

小端模式: 反过来, 和日常的数字计算在方向上是一致的.

处理器, jvm采用的是小端模式.

网络协议都是大端模式, 也称为网络字节序.

无锁, 偏向锁, 轻量级锁和重量级锁

在jdk1.6之前所有的java内置锁都是重量级锁, 重量级锁会会造成CPU在用户态和核心态之间频繁切换, 代价很高, 为了高性能, 1.6引入了偏向锁和轻量级锁, 一共就有了四种锁状态: 无锁, 偏向锁, 轻量级锁, 重量级锁, 锁的状态会随着竞争升级而升级, 但是不能降级.

无锁

对象创建后, 没有任何线程来竞争锁时, 对象就是处于一种无锁的状态, 偏向锁标志位: 0, 锁状态标志位: 01, 其mark word如下:

25位(未使用)31位(HashCode)1位(未使用)4位(age)001

偏向锁

当一个对象创建后, 一直被同一个线程访问, 那么这个线程就可以自动获取到这个对象锁, 降低获取锁的开销, 如果对象锁处于偏向锁状态, 当一个线程来加锁时, 先用偏向锁, 表示内置锁偏爱这个线程, 在竞争不激烈的情况下非常高效.

偏向锁状态的Mark Word会记录内置锁自己偏爱的线程id在对象头中, 偏向锁状态下的对象头mark word:

54位(线程id)2位(epoch)1位(未使用)4位(age)101

轻量级锁

当有两个线程开始竞争同一个java对象锁时, 就不能使用偏向锁了, 锁会升级为轻量级锁, 两个线程公平竞争, 哪个线程先占有锁对象, 锁对象的markword就指向哪个线程的线程占中的锁记录, 对象头如下:

62位(锁记录指针00

没有抢到锁的线程会尝试通过自旋的方式来获取锁, 线程不会被阻塞, 从而避免线程的用户态核心态切换带来的性能开销.

自旋的原理就是, 持有锁的线程如果在短时间内释放了锁, 那么自旋就可以成功的拿到锁, 避免了挂起唤醒的重量级操作带来的性能开销. 自旋会有一定的限制,不可能一直自旋下去, 防止消耗CPU资源. 如果指定时间段内或者次数没有获取到锁, 锁就会再次升级为重量级锁.

重量级锁

重量级锁就是让无法获取到锁的线程进入阻塞状态, 也称同步锁, 锁对象的mark word会指向一个对象监视器, 在监视器中用集合的形式登记和管理排队的线程, mark word 状态如下:

62位(Monitor对象指针)10

偏向锁的原理与实战

使用场景

用来解决无竞争场景下的加锁性能问题, 所谓偏向锁就是偏心, 锁会偏向已经占有锁的线程.

核心原理

 在没有竞争的状况下, 一个线程与加锁时, 默认加锁对象就会进入偏向锁状态, 修改偏向锁标志位: 1, 锁对象标志位: 01, 然后线程ID通过cas操作记录在mark word中, 以后这个线程再次加锁时, 直接判断线程id和标志位就可以直接进入临界区, 连cas操作都不需要, 从而节省了大量的关于锁的申请操作, 提升了程序的性能. 

偏向锁主要使用在无竞争场景下的加锁场景, 从而提升性能, 如果在加锁时发现线程id不是自己的线程id, 通过cas操作把线程id设置为自己的线程id, 然后进入临界区, 如果cas失败那么表示有竞争, 抢锁线程会被挂起, 撤销占锁线程的偏向锁, 然后将偏向锁膨胀为轻量级锁.

如果锁对象经常有竞争, 偏向锁就会很多余, 并且撤销偏向锁也会带来一定的开销.

使用示例

public class InnerLockTest {

    public static void main(String[] args) throws InterruptedException {

        Object lock = new Object();

        Thread thread = new Thread(()->{
            IntStream.rangeClosed(1, 10).forEach(i->{
                synchronized (lock) {
                    System.out.println(i);
                }
            });
        });
        thread.start();
        thread.join();
    }
}

lock对象一直只会有一个线程thread来加锁, 所以不会产生竞争, 锁就一直保持在最轻量级的偏向锁状态, 且偏向线程为thread线程. 可以通过jol工具查看.

偏向锁的膨胀和撤销

如果偏向锁已经处于偏向锁状态, 对象锁已经有所偏向, 其他竞争线程发现偏向的线程不是自己, 就存在了竞争, 尝试撤销偏向锁, 然后膨胀到轻量级锁.

撤销

  1. 在安全点停止所有的线程
  2. 遍历线程栈帧, 清空偏向锁记录, 使其变为无锁状态, 修复锁记录的mark word, 清楚线程id.
  3. 当前锁升级为轻量级锁.
  4. 唤醒当前线程

撤销偏向锁的条件:

  1. 多个线程竞争偏向锁
  2. 调用偏向锁对象的hashcode()方法或者System.identityHashCode()方法计算对象的HashCode之后, 将哈希值放置到MarkWord中, 内置锁变为无锁状态, 偏向锁将被撤销.

膨胀

有竞争了, 就膨胀了.

可以看到在没有竞争的加锁情况下使用效果还是不错的. 其带来的好处大于撤销和膨胀带来的缺点.

轻量级锁的原理与实战

在竞争不激烈时, 偏向锁会首先升级为轻量级锁, 轻量级锁简单来说就是通过cas操作减少重量级锁产生的情况.

核心原理

引入轻量级锁是为了减少重量级锁的介入, 应为重量级锁都涉及到系统层面的互斥锁, 且性能比较差, 需要线程频繁的在用户态和内核态之间切换, 如果线程持有锁的时间很短, 那就显得线程切换带来的系统开销更大, 更不值得引入重量级锁, 所以通过引入轻量级锁, 通过轻量级锁的自旋让竞争线程在进入阻塞状态之前多次通过cas尝试加锁, 如果成功就避免不必要的重量级锁开销, 从而提升效率.

轻量级锁的执行过程:

抢占锁线程进入临界区之前, 如果内置锁没有被锁定, jvm首先在抢占线程的栈帧中建立一个锁记录(Lock Record), 用于存储对象目前Mark Word的拷贝

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/06958a6391d044d9afcd29302bb47ee8~tplv-k3u1fbpfcp-zoom-1.image

然后抢锁线程进入cas操作, 尝试把内置锁对象头的Mark Word的prt_to_lock_record(锁记录指针)更新为抢锁线程栈中的锁记录地址. 如果成功, 就是抢锁成功, 线程就拥有了锁对象, 然后jvm把对象头中的锁标识位修改为:00标识轻量级锁. 原来对象头中的信息保存在线程栈中的锁记录中. 再把线程栈中的锁记录的owner指向锁对象.

加锁成功后的锁记录和对象头状态:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4ea88f612e6341f2a57ff23786e7c591~tplv-k3u1fbpfcp-zoom-1.image

当加锁线程加锁成功后, 会把锁对象头中的mark word信息hash值, age 等复制到锁记录中, 因为mark word要用来记录锁记录地址信息, 保存是为了锁释放的时候使用准备的.

轻量级锁的分类

普通自旋锁和自适应自旋锁

普通自旋锁是加锁线程在尝试加锁时循环固定的次数, 默认10次, 如果无限循环下去会造成cpu的浪费, 在固定的循环次数内如果拿到了锁就加锁成功, 成功避免锁升级带来的性能消耗, 普通自旋锁的自旋次数可以通过: -XX: PreBlockSpin选项来修改.

自适应自旋锁

如果上一次自旋成功过, 说明自旋的成功可能性比较高, 可以让线程自旋多一些.

如果对于某一个锁很少有通过自旋方式加锁成功过, 说明执行自旋的必要性不大, 可以适当的减少自旋次数或者直接跳过自旋.

基本原理就是根据上一次的自旋结果调整下一次的自旋次数.

轻量级锁也称为: 非阻塞同步锁, 乐观锁. 应为线程没有被挂起, 而是空循环了一会.

如果自旋失败了, 轻量级锁就会升级为重量级锁.

重量级锁的原理与实战

在JVM中每个对象都关联一个监视器对象, 包含Class对象, 是一个同步工具, 只有拿到监视器的线程才能进入临界区进行操作, 没有拿到就需要等待, 重量级锁就是通过监视器对象来保证任何时间只能有一个线程能够进入临界区.

原理

JVM中每个对象都会有一个监视器, 监视器和对象一起被创建, 销毁, 相当于一个用来监视这些线程的特殊房间, 保证同意时刻只有一个线程能访问临界区代码.

  1. 同步: 进入临界区需要获得监视器, 离开归还监视器
  2. 协作: 提供signal机制, 允许持有证的线程暂时放弃许可, 进入等待, 等待其他线程发送signal来唤醒正在等待的线程, 拥有许可的线程可以发送signal, 唤醒正在等待的线程.

在Hotspot虚拟机中. 监视器由C++类ObjectMonitor实现, 定义在ObjectMonitor.hpp文件中.

//Monitor结构体
ObjectMonitor::ObjectMonitor() {
_header   = NULL;  
_count    = 0;  
_waiters  = 0,
//线程的重入次数
_recursions  = 0;
_object   = NULL;
////标识拥有该Monitor的线程
_owner    = NULL;
//等待线程组成的双向循环链表
_WaitSet  = NULL;  
_WaitSetLock  = 0 ;  
_Responsible  = NULL ;  
_succ     = NULL ; 
//多线程竞争锁进入时的单向链表  
cxq       = NULL ;  
FreeNext  = NULL ; 
//_owner从该双向循环链表中唤醒线程节点  
_EntryList= NULL ;  
_SpinFreq = 0 ;  
_SpinClock= 0 ;  
OwnerIsThread = 0 ;
}

ObjectMonitor的Owner, WaitSet, Cxq, EntryList几个属性比较关键, 其中wait, cxq, entryList这三个队列存放抢夺重量级锁的线程. Owner指向拥有重量级锁的线程.

三个队列

Cxq: 竞争队列, 所有请求锁的线程都会被放入这个竞争队列中.

EntryList: Cxq中哪些有资格成为候选资源的线程被移入EntryList中.

WaitSet: 拥有锁的线程, 执行了Object.wait方法后, 进入阻塞状态, 被放入WaitSet队列中.

ObjectMonitor内部抢锁过程:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/599611b8baaa406dac2cf6003a0ebfdc~tplv-k3u1fbpfcp-zoom-1.image

  1. Cxq, 由Node和next指针构成, 不是一个具体的队列结构, 每次新加入Node会在Cxq的对头入队, 通过cas改变第一个节点的指针为新增节点, 同时设置新增Node的next指向后续节点; 从cxq获取元素时, 从队尾获取, 是一个无锁结构, 因为只有Owner线程才能从队尾获取元素, 所以不存在竞争.
💡 在线程进入cxq之前, 线程会先尝试通过cas自旋获取锁, 如果获取不到就进入cxq队列, 如果获取成功就不用进入cxq队列, 所以synchronized不是一个公平锁
  1. EntryList

    也是一个等待队列, Cxq会有多个线程同时入队, 为了降低对Cxq队列的争用, 所以建立了EntryList, 在Owner线程释放锁时, JVM会从cxq队尾迁移线程到EntryList, 并会指定EntryList中的某个线程, 一般为头结点, 为onDeck(Ready Thread), EntryList中的线程作为候选竞争线程而存在.

  2. OnDeck Thread, Owner Thread

    Jvm不会把锁直接给Owner Thread, 而是把锁竞争权利交给OnDeck Thread. OnDeck需要重新竞争, 如果竞争成功就变为Owner Thread, 失败就继续存在于EntryList中. 继续保持在队头的位置. 由于新线程在进入cxq队列之前会先通过cas尝试加锁, 所以对onDeckThread非常不公平.

  3. WaitSet

    Owner线程在释放锁之前, 调用Object.wait()方法进入阻塞, 然后被转移到WaitSet中, 直到某个时刻, 新的Owner Thread通过调用Object.notify或者notifyAll()来唤醒, 然后该线程重新进入EntryList中.

处于Cxq, EntryList, WaitSet中的线程都处于阻塞状态, 线程的唤醒和阻塞都需要操作系统来帮忙, 然后程序的执行是在用户态下执行, 所以阻塞和唤醒操作都会涉及到用户态和内核态的切换. 非常的重量级.

偏向锁, 轻量级锁与重量级锁对比

synchronized执行过程

  1. 线程加锁时, 首先检测内置锁对象的Mark Word中的偏向锁标识(biased_lock)是否为1, lock标识是否为1, 从而确保内置锁对象是否为可偏向状态.
  2. 如果是可偏向状态, 线程会判断偏向线程是否为当前线程id, 如果是就直接进入临界区执行,
  3. 如果偏向锁状态不是当前线程, 就尝试通过cas修改为当前线程, 如果竞争成功就把偏向锁的偏向线程设置为当前线程, 偏向锁标识设置为1, 锁标识设置为01 , 然后执行临界区代码.
  4. 如果cas操作失败, 说明发生了竞争, 撤销偏向锁, 升级为轻量级锁.
  5. JVM使用cas把对象的Mark Word替换为抢锁线程栈中锁记录指针, 如果成功, 就加锁成功, 如果失败尝试通过自旋加锁, 如果自旋成功, 就加锁成功, 把对象头中的轻量级锁指针指向线程栈中锁记录, 然后把对象头中的其他信息存放在线程栈中的锁记录中, 锁记录中的Owner指向内置锁对象.
  6. 如果自旋失败, 说明竞争比较激烈, 就升级为重量级锁, 等待的线程进入ObjectMonitor的等待队列, 进入阻塞状态.

详细对比:

优点缺点适用场景
偏向锁加锁和解锁都不需要额外的消耗, 执行效率最高效.如果竞争升级, 撤销会带来额外的开销无竞争加锁场景
轻量级锁竞争的线程不会进入阻塞状态, 提高了程序的响应自旋会消耗cpu自旋锁占用时间非常端, 竞争不激烈
重量级锁线程竞争不用自旋, 不会消耗cpu资源线程阻塞唤醒需要操作系统介入, 响应速度慢锁占用时间长, 竞争非常激烈

线程间通信

线程是系统调度的最小单元, 每个线程都有自己的栈空间, 且各自的栈空间都是互相独立的, 如果完全孤立的运行就没有意义. 现实中往往需要多个线程协同完成一项工作. 就需要在多个线程之间互相协调, 这个协调的过程也称为线程通信

定义

当多个线程共享一个资源时, 线程之间通过某种方式告知自己的状态, 以避免无效的资源争夺.

线程间通信的方式支持很多种, 常见的有: 等待-通知, 共享内存, 管道流.

低效的线程轮询

在之前的生产者消费者模式中, 当队列满, 或者空的时候, 线程还是在空运行, 相当于还在使用cpu时间片,应该让无法满足生产或者消费的时候, 让生产者消费者停下来, 等待可执行时再被唤醒继续工作, 这就是典型的: 等待-通知 线程间通信机制.

当队列满时, 让生产者阻塞, 等消费者消费了数据后, 队列可容纳新任务时, 消费者通知生产者, 生产者从阻塞中唤醒, 并继续执行生产操作.

队列为空时, 让消费者阻塞, 生产者通知消费者, 基本原理同上

Java语言中”等待-通知”方式的线程间通信使用对象的notify()和wait()两类方法来实现, 每个Java对象都有wait和notify方法. 并且wait和notify和对象监视器紧密相关.

wait方法和notify方法的原理

wait方法

在同步块中调用对象的wait方法,使得拥有对象锁的线程进入等待状态. 三个重载版本

wait

wait(time)

wait(time, nanos) //精度更高的版本.

调用对象的wait, notify方法之前, 必须先拥有对象的监视器, 否则报错

Exception in thread "main" java.lang.IllegalMonitorStateException

原理

在同步方法内调用持有锁对象的wait方法后, jvm会将当前线程加入到waitSet中, 等待被其他线程唤醒.

当前线程进入waitSet后会释放锁对象的监视器的Owner权利, 其他线程就可以获取到锁对象的监视器了.

当前线程等待, 状态变成: WAITTING

从线程加锁成功到线程调用wait方法, 锁对象内部监视器内部状态经历了

entrylist→onDeckThread→Owner→Waitset

notify方法

锁对象的notify方法, 用来唤醒在等待的线程, 也是和对象监视器紧密相关.

两个版本:

notify: 唤醒WaitSet中第一条等待线程, 被唤醒线程进入EntryList, 状态由WAITTING→BLOCKED

notifyAll(): 唤醒waitSet中的所有等待线程, 全部进入EntryList, 且状态全部修改为: blocked.

原理

当线程调用锁对象的notify后, jvm会唤醒waitSet中第一条线程.

当线程调用锁对象的notifyAll方法后, jvm会唤醒WaitSet中的所有线程.

线程被唤醒后, 从waitset移动至entrylist, 从而具有竞争锁的资格, 状态从waiting→blocked.

然后就是简单的加锁过程了.

等待-通知通信模式

public class WaitTest {
    public static void main(String[] args) throws InterruptedException {
        Object lock1 = new Object();
        BufferedReader reader = new BufferedReader(
                new InputStreamReader(System.in));

        // Reading data using readLine

        Thread waitThread = new Thread(()->{
            try {
                synchronized (lock1) {
                    System.out.println("1加锁成功");
                    System.out.println("1进入阻塞, 释放锁");
                    lock1.wait();
                    System.out.println("1从阻塞中被唤醒");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        Thread notifyThread = new Thread(()->{
            try {
                synchronized (lock1) {
                    String name = reader.readLine();
                    System.out.println("2加锁成功");
                    System.out.println("2唤醒了1");
                    lock1.notify();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        waitThread.setName("waitThread");
        notifyThread.setName("notifyThread");

        waitThread.start();
        notifyThread.start();
        waitThread.join();
    }
}

启动程序后, 执行jps查看java进程, jstack查看具体进程信息, 可以看到具体进程如图:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e449a747c1fc42e59b1f82f8a6fdfd0c~tplv-k3u1fbpfcp-zoom-1.image

可以看到在waitThread的状态为: WAITING, notifyThread的状态为: RUNNABLE

因为waitThread调用了wait方法, 进入等待状态, 所以此时不会消耗cpu资源, notify线程在等待用户输入, 所以处于Runnable状态,

输入任意内容后, notify线程调用notify方法唤醒waitThread, 然后waitThread进入entryList, 状态变为: blocked.

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/17ff7d761bf840f7ba6d536b8d2bc7c8~tplv-k3u1fbpfcp-zoom-1.image

再次输入任意内容, notify线程释放锁, waitThread加锁执行完毕.

生产者消费者通信模式

public void put2(T t) throws InterruptedException {
		// 队列满了, 需要阻塞添加操作, 并在队列未满时能够被唤醒, 在fetch后必定可以执行添加操作, 所以需要在fetch后唤醒这个对象锁.NOT_FULL
    while (size >= limit) {
        synchronized (NOT_FULL) {
            System.out.println("队列已满");
            NOT_FULL.wait();
        }
    }
		// 添加元素, 和fetch互斥, 防止发生线程安全问题
    synchronized (LOCK_OBJECT) {
        cache.add(t);
        size++;
    }
		// 添加后, 通知fetch方法, 可以获取元素了.
    synchronized (NOT_EMPTY) {
        NOT_EMPTY.notify();
    }
}

public T fetch2() {
		// 队列为空, 需要阻塞获取操作, 并在队列中有元素后能够被唤醒, 所以在添加成功后需要通知NOT_EMPTY对象锁.
    while (size <= 0) {
        synchronized (NOT_EMPTY) {
            System.out.println("队列已空");
            NOT_EMPTY.wait();
        }
    }
    T ele=null;
		// fetch操作
    synchronized (LOCK_OBJECT) {
        ele = cache.remove(0);
    }
		// 通知put操作可以执行了.
    synchronized (NOT_FULL) {
        NOT_FULL.notify();
    }
    return ele;
}

一开始判断:

如果是添加操作, 发现满了, 就不能添加了, 所以not_full是个假命题, 就通过这个对象锁来阻塞添加操作.

如果移除操作, 发现空了, 那么not_empty就是个假命题, 所以就通过not_empty来阻塞移除操作.

在添加操作执行完成后, not_empty就为真了, 所以通过notempty唤醒被阻塞的线程,

在移除操作完成后, not_full就是真了, 所以要唤醒阻塞在not_full上阻塞的线程.

需要在synchronized同步块内使用的wait和notify

为什么?

应为wait, notify操作都涉及到一些列的对象监视器的操作, 并且会涉及到owner线程的操作, 如果你都不是owner, 你随意操作owner是其他线程的资源, 整个系统就乱了套了.