跳到主要内容

并发编程-Synchronized详解

· 阅读需 13 分钟
文浩Marvin

设计同步器的意义

在多线程编程中,可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源被称为临界资源。为了保证对临界资源的访问是安全的,需要采用同步机制来协调多个线程对该资源的访问。同步机制可以保证在同一时刻只有一个线程能够访问临界资源,从而避免了数据竞争和并发安全问题。

设计同步器的意义在于提供一种通用的、可重用的同步机制,使得开发人员可以更方便地实现自己的并发控制逻辑。同步器可以看作是一种抽象数据类型,它定义了一组接口和规范,使得开发人员可以通过实现这些接口和规范来定制自己的同步策略。

Java中提供了多种同步器实现,如synchronized、ReentrantLock、Semaphore等。除此之外,Java还提供了一种通用的同步器框架——AQS(AbstractQueuedSynchronizer),它提供了一些基本操作(如获取锁、释放锁等)和状态管理机制,并允许开发人员通过继承AQS类来实现自己的同步器。通过使用AQS框架,开发人员可以更加灵活地设计自己的同步器,从而满足不同场景下的并发控制需求。

如何解决线程并发安全问题?

在多线程编程中,为了保证对临界资源的访问是安全的,需要采用同步机制来协调多个线程对该资源的访问。同步机制可以保证在同一时刻只有一个线程能够访问临界资源,从而避免了数据竞争和并发安全问题。

Java中提供了两种方式来实现同步互斥访问:synchronized和Lock。synchronized是一种内置锁,它是基于JVM内置锁实现的,通过内部对象Monitor(监视器锁)实现方法同步和代码块同步。Lock是一种显式锁,它提供了更加灵活的锁定方式,并允许开发人员手动控制锁的获取和释放。

无论是使用synchronized还是Lock,都需要遵循以下原则:

  1. 对临界资源进行加锁操作;
  2. 在加锁期间,其他线程不能访问该资源;
  3. 在解锁后,其他线程才能继续访问该资源。

除此之外,在使用同步机制时还需要注意以下问题:

  1. 避免死锁:当多个线程相互等待对方释放锁时会导致死锁;
  2. 防止饥饿:某些线程可能会因为竞争不到资源而一直处于等待状态,导致饥饿现象;
  3. 提高并发性能:过多的锁竞争会导致性能下降,因此需要采用一些优化措施来提高并发性能,如锁粗化、锁消除、轻量级锁、偏向锁等。

synchronized底层原理

synchronized是Java中最基本的同步机制,它是一种内置锁,通过内部对象Monitor(监视器锁)实现方法同步和代码块同步。synchronized的底层原理主要涉及到以下几个方面:

  1. Monitor对象:每个Java对象都有一个与之关联的Monitor对象,它用于实现对象的同步机制。Monitor对象包含了一个EntryList和一个WaitSet,EntryList用于存储已经获取了Monitor对象锁的线程,WaitSet用于存储等待获取Monitor对象锁的线程。

  2. synchronized关键字:synchronized关键字可以用于修饰方法和代码块,它的作用是获取对象的Monitor对象锁。当一个线程获取了Monitor对象锁后,其他线程就不能再获取该对象的Monitor对象锁,只能等待该线程释放锁。

  3. monitorenter和monitorexit指令:monitorenter指令用于获取Monitor对象锁,monitorexit指令用于释放Monitor对象锁。在Java字节码中,synchronized关键字就是通过monitorenter和monitorexit指令来实现的。

  4. synchronized的实现原理:当一个线程尝试获取对象的Monitor对象锁时,如果该对象的Monitor对象已经被其他线程获取了,那么该线程就会进入EntryList中等待。当Monitor对象锁被释放时,Monitor对象会从EntryList中选择一个线程唤醒,唤醒的线程会重新尝试获取Monitor对象锁,如果获取成功就可以继续执行,否则就继续等待。

synchronized是基于JVM内置锁实现的,通过内部对象Monitor(监视器锁)实现方法同步和代码块同步。每个同步对象都有一个与之关联的Monitor对象,当一个线程进入同步块时,它会尝试获取该同步对象的Monitor锁。如果该锁没有被其他线程占用,则该线程可以获取到该锁并进入临界区;否则,该线程就会被阻塞,直到其他线程释放了该锁。

在JVM中,synchronized关键字被编译成monitorenter和monitorexit两条指令来实现。当一个线程进入同步块时,它会执行monitorenter指令来获取Monitor锁;当它退出同步块时,会执行monitorexit指令来释放Monitor锁。

在JVM中,Monitor锁有四种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。在不同的情况下,JVM会根据当前的竞争情况自动选择合适的状态来提高并发性能。例如,在竞争不激烈的情况下,JVM会使用偏向锁或轻量级锁来减少加锁和解锁操作的开销;而在竞争激烈的情况下,则会使用重量级锁来保证线程安全。

详细输出Monitor监视器锁的实现过程,带有代码执行演示

public class MonitorDemo {
private static int count = 0;
private static Object lock = new Object();

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (lock) {
count++;
}
}
});

Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (lock) {
count--;
}
}
});

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

try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("count = " + count);
}
}

上面的代码演示了如何使用synchronized关键字来实现线程同步。在该代码中,我们定义了一个静态变量count和一个静态对象lock,然后创建了两个线程t1和t2,分别对count进行加1和减1操作。由于count是一个共享变量,因此需要使用synchronized关键字来保证线程安全。

在synchronized关键字的内部实现中,使用了Monitor(监视器锁)来实现线程同步。Monitor是一种同步机制,它可以用于实现方法同步和代码块同步。在Java中,每个对象都有一个与之关联的Monitor对象,当一个线程进入同步块时,它会尝试获取该对象的Monitor锁。如果该锁没有被其他线程占用,则该线程可以获取到该锁并进入临界区;否则,该线程就会被阻塞,直到其他线程释放了该锁。

下面是Monitor的实现过程:

  1. 当一个线程尝试获取Monitor锁时,如果该锁没有被其他线程占用,则该线程可以获取到该锁并进入临界区;否则,该线程就会被阻塞,直到其他线程释放了该锁。

  2. 当一个线程释放Monitor锁时,如果有其他线程在等待该锁,则Monitor会从等待队列中选择一个线程唤醒,唤醒的线程会重新尝试获取Monitor锁,如果获取成功就可以继续执行,否则就继续等待。

  3. Monitor锁有四种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。在不同的情况下,JVM会根据当前的竞争情况自动选择合适的状态来提高并发性能。例如,在竞争不激烈的情况下,JVM会使用偏向锁或轻量级锁来减少加锁和解锁操作的开销;而在竞争激烈的情况下,则会使用重量级锁来保证线程安全。

下面是代码执行的演示结果:

count = 0

在该代码中,我们使用了synchronized关键字来保证线程安全,通过Monitor锁来实现线程同步。由于count是一个共享变量,因此需要使用synchronized关键字来保证线程安全。在synchronized关键字的内部实现中,使用了Monitor(监视器锁)来实现线程同步。Monitor是一种同步机制,它可以用于实现方法同步和代码块同步。在Java中,每个对象都有一个与之关联的Monitor对象,当一个线程进入同步块时,它会尝试获取该对象的Monitor锁。如果该锁没有被其他线程占用,则该线程可以获取到该锁并进入临界区;否则,该线程就会被阻塞,直到其他线程释放了该锁。

对象的内存布局

Java对象的内存布局包括以下组成部分:

  1. 对象头:它包含两部分信息 - 标记字和类指针。
  2. 实例数据:它存储对象的属性及其值。
  3. 填充:它是可选的,用于字节对齐。

对象头中的标记字存储运行时数据,例如哈希码、GC代龄、锁状态标志、持有锁的线程、偏向线程ID和偏向时间戳等。类指针指向对象的类元数据。

总体而言,Java对象的内存布局旨在优化空间效率,同时仍提供足够的信息以进行运行时操作,例如垃圾回收和锁定。

锁的膨胀升级过程

在Java中,锁的膨胀升级过程是指在不同的竞争情况下,JVM会自动选择合适的锁状态来提高并发性能。锁的状态包括无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。在不同的情况下,JVM会根据当前的竞争情况自动选择合适的状态来提高并发性能。

下面是锁的膨胀升级过程:

  1. 无锁状态:当一个线程访问一个没有被锁定的对象时,JVM会将该对象的Mark Word设置为当前线程的ID,表示该对象被该线程所拥有。这种状态下,线程可以无阻塞地访问该对象。

  2. 偏向锁状态:当一个线程访问一个被偏向锁标记的对象时,JVM会将该对象的Mark Word设置为当前线程的ID,并将偏向锁标记设置为1。这种状态下,线程可以无阻塞地访问该对象。如果其他线程也要访问该对象,JVM会撤销偏向锁,并将对象的Mark Word设置为无锁状态。

  3. 轻量级锁状态:当一个线程尝试获取一个被其他线程持有的锁时,JVM会将该对象的Mark Word复制一份到线程的栈帧中,并将对象的Mark Word设置为指向线程栈帧的指针。然后,JVM会使用CAS操作尝试将对象的Mark Word设置为指向线程栈帧的指针。如果CAS操作成功,则该线程获得了锁,并可以无阻塞地访问该对象。如果CAS操作失败,则表示有其他线程正在竞争该锁,JVM会将锁膨胀为重量级锁。

  4. 重量级锁状态:当一个线程尝试获取一个被其他线程持有的锁时,JVM会将该线程阻塞,并将其加入到等待队列中。当锁被释放时,JVM会从等待队列中选择一个线程唤醒,并将该线程的状态设置为就绪状态。然后,JVM会使用CAS操作尝试将对象的Mark Word设置为指向线程栈帧的指针。如果CAS操作成功,则该线程获得了锁,并可以无阻塞地访问该对象。如果CAS操作失败,则表示有其他线程正在竞争该锁,JVM会将该线程重新加入到等待队列中。

锁的膨胀升级过程可以帮助JVM在不同的竞争情况下自动选择合适的锁状态,从而提高并发性能。在竞争不激烈的情况下,JVM会使用偏向锁或轻量级锁来减少加锁和解锁操作的开销;而在竞争激烈的情况下,则会使用重量级锁来保证线程安全。

锁的膨胀升级过程是指在多线程并发访问时,为了提高性能和减少锁竞争,锁的粒度会逐渐升级,从细粒度锁到粗粒度锁的过程。锁的膨胀升级过程一般包括以下几个阶段:

  1. 偏向锁:在没有竞争的情况下,将锁对象标记为偏向锁,并将线程ID记录在锁对象头中。当同一线程再次请求锁时,无需竞争,直接获取锁。

  2. 轻量级锁:当多个线程竞争同一锁对象时,JVM会将锁对象标记为轻量级锁,并将锁对象头中的指针指向当前线程的栈帧。此时,线程会通过CAS操作尝试获取锁,如果成功则执行同步代码块,否则进入自旋等待。

  3. 自旋锁:当线程无法获取轻量级锁时,会进入自旋等待状态,不断尝试获取锁。自旋等待的时间一般很短,如果在自旋等待期间锁被释放,则线程可以立即获取锁。

  4. 重量级锁:当自旋等待一定次数后,仍无法获取锁时,锁对象会被升级为重量级锁。此时,线程会进入阻塞状态,释放CPU资源,等待锁被释放后再次竞争。

锁消除

锁消除是通过静态分析代码,判断某些锁不会被多个线程同时访问,从而将这些锁消除掉,以提高程序的性能。

举个例子,假设有如下代码:

public class Test {
public void method() {
StringBuffer sb = new StringBuffer();
sb.append("Hello");
sb.append("World");
}
}

在这段代码中,StringBufferappend()方法是一个同步方法,会对sb对象进行加锁。但是由于sb对象只在当前方法中使用,并且不会被其他线程访问到,因此这个锁可以被消除掉。

经过编译器优化后的代码可能如下所示:

public class Test {
public void method() {
StringBuffer sb = new StringBuffer();
sb.append("Hello");
sb.append("World");
}
}

可以看到,在优化后的代码中已经没有加锁操作了。这样就可以避免不必要的锁竞争,提高程序的性能。

Loading Comments...