线程-并发-锁
在操作系统中总会提到进程和线程,那么进程和线程的区别是什么呢?
- 进程(Process)是系统进行资源分配和调度的基本单位。
- 线程(thread)是操作系统能够进行运算调度的最小单位。
这是百度上对进程和线程的解释,看起来依然不是很容易理解。事实上,计算机的核心是CPU,它是计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元,但是每个CPU一次只能处理一个任务,即运行一个进程。如果在运行一个进程的同时,想要运行另外一个进程,就需要先把当前的进程挂起,进行上下文切换,才能转而去运行其他进程。
在一个进程中,要想提高执行效率,可以通过在一个进程中创建多个线程,多个线程并发执行,线程之间可以进行资源共享,但是多个线程在修改同一个资源时,可能会出现错误。
public class Test3 implements Runnable {
private static int ticketNum = 10;
public static void main(String[] args) {
Test3 test3 = new Test3();
new Thread(test3,"小一").start();
new Thread(test3,"小二").start();
new Thread(test3,"小三").start();
}
@Override
public void run() {
while (true) {
if (ticketNum <= 0) {
break;
}
try {
Thread.sleep(200);
}catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "买到了第" + ticketNum-- + "张票");
}
}
}
小二买到了第9张票
小三买到了第8张票
小一买到了第10张票
小一买到了第7张票
小三买到了第6张票
小二买到了第5张票
小二买到了第3张票
小三买到了第4张票
小一买到了第4张票
小二买到了第2张票
小一买到了第1张票
小三买到了第0张票
结果中可以看到,有两个人同时买到了一张票,为了避免出现这种情况,我们在线程竞争同一资源时引入了锁这个概念。
并行:在同一时刻只能有一条指令执行,多个进程指令被快速的轮换执行。(CPU数量)(一个CPU执行一个进程)
并发:在同一时刻,有多条指令在多个处理器上同时执行。(解决同一个问题)(在同一个CPU上)
1.线程是否对同步资源加锁
1.1 不加锁:乐观锁
----乐观锁:对数据冲突保持乐观,认为当前线程在使用数据时,不会有其他线程修改数据,所以不对数据加锁。
----只在提交更新的数据时,才会通过某种手段来判断是否有其他线程修改了数据,最常采用的是CAS算法。
----如果数据没有被修改,当前线程就将自己修改的数据成功写入。如果数据已经被其他线程修改,则根据不同的实现方式执行不同的操作(如报错或自动重试)。
1.1.1 CAS
CAS全称Compare And Swap(比较与交换),是一种无锁算法。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
当且仅当 V = A 时,CAS通过原子方式将V值更新为B(“比较+更新”整体是一个原子操作),否则不执行任何操作。一般情况下,“更新”是一个不断重试的操作。
通过automicInteger源码来看一下:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final jdk.internal.misc.Unsafe U= jdk.internal.misc.Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class,"value");
private volatile int value;
- jdk.internal.misc.Unsafe:用于获取并操作内存中的数据。
- value:存储 AtomicInteger 的 int 值,该属性借助 volatile 关键字保证其在线程间可见。
- VALUE:存储 value 在 AtomicInteger 中的偏移量。
进入 automicInteger 的 incrementAndGet() 方法,底层调用的 U.getAndAddInt()。
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
进入 getAndAddInt 方法:
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while(!weakCompareAndSet(o, offset, v, v+ delta));
return v;
}
进入 weakCompareAndSet 方法:
public final boolean weakCompareAndSet(Object o, long offset, int expected,int x) {
return compareAndSetInt(o,offset,expected,x);
}
- getIntVolatile:获取对象中 offset 偏移地址对应的整型 field 的值,即内存值 V;
- excepted:期望值 A;
- 若两值相等,就跳出 while 循环,返回期望值 v,并将内存值更新为 v+delta;若两值不等,就取消赋值,返回 false。
举例:
----内存中的值是 1,通过 getIntVolatile 方法将内存中的值赋值给 v,此时 v=1;
----如果此时有其他线程将内存中的值更改为 3,在调用 weakCompareAndSetInt 方法时,内存中的值 3 和期望的值 1 不相等,重新通过 getIntVolatile 方法给将内存中最新的值赋值给 v,此时 v=3;
----如果没有其他线程更改,在调用 weakCompareAndSetInt 时,内存中的值 3 和期望的值 3 相等,就 return v=3 就可以了。哈哈哈,很简单吧!
1.1.2 为什么无锁效率高
- 无锁情况下,即使重试失败,线程始终在高速运行,而 synchronized会让线程在没有获得锁时,发生上下文切换,进入阻塞,而上下文切换代价较大。
- 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,没有额外 CPU 支持时,线程虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
- 所以无锁适用于线程数量少,多核CPU的情况。
1.1.3 CAS存在的三个问题
- ABA问题。
CAS在更新内存中的值时,首先要检查该值是否被更改,没有被更改才会更新内存值。如果内存中的值原来是A,变成了B,又变成了A,CAS检查时发现值没有变化,但实际已经变化了。
- ABA问题的解决思路:在变量前添加版本号,每次变量更新时都把版本号加1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
- JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet() 中。compareAndSet() 首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果相等,则以原子方式更新引用值和标志的值。
- 循环时间长开销大。
CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
- 只能保证一个共享变量的原子操作。
对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 无法保证操作的原子性。
1.2 加锁
悲观锁:对数据冲突保持悲观,认为当前线程在使用数据时,一定会有其他线程修改数据,所以在获取数据前先对数据加锁,确保数据不会被其他线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
2.锁住同步资源失败,线程是否阻塞
2.1 阻塞
Wait:释放锁
Sleep:不释放锁
2.2 不阻塞
阻塞或唤醒一个线程需要上下文切换,这个过程非常耗费处理器时间。如果同步代码块中的内容过于简单,上下文切换消耗的时间可能比用户代码执行的时间还长,这样就得不偿失。
如果计算机有多个CPU,可以同时运行多个线程,就让请求锁的线程暂时保留 CPU 时间片,等待持有锁的线程释放锁。这个等待的过程就是自旋,如果在自旋完成后,前面锁定同步资源的线程已经释放了锁,那么当前线程就不必阻塞,直接获取同步资源,从而避免上下文切换的开销,这就是自旋锁。
- 虽然自旋锁能够节省上下文切换开销,但是也不能代替阻塞,因为它占用处理器时间。
- 如果锁被占用的时间很短,自旋等待的效果就很好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。
- 所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)还没有成功获得锁,就应当挂起线程。
自旋锁的实现原理是 CAS,AtomicInteger 中的自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
3.多个线程竞争同步资源的流程
3.1 Synchronized如何实现线程同步?
首先了解一下对象结构:
epoch:本质是一个时间戳,代表偏向锁的有效性。
- Java对象头
synchronized 是悲观锁,需要在操作同步资源之前给同步资源先加锁,这把锁在 Java 对象头里。
Java 对象头又是什么呢?以Hotspot虚拟机为例,对象头主要包括两部分:Mark Word(标记字段)、Class Pointer(类型指针)。
- Mark Word:用于存储对象自身的运行时数据。
这些信息都与对象自身定义无关,所以Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存中存储尽量多的数据。它会根据对象的状态复用自己的存储空间,即在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- Class Pointer:对象指向它的类元数据的指针。
虚拟机通过这个指针来确定这个对象是哪个类的实例。
- Monitor
Monitor是线程私有的数据结构,每个线程都有一个monitor record列表和一个全局的可用列表。
- 每一个被锁住的对象都会关联一个 monitor,monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
synchronized 通过 Monitor 实现线程同步,Monitor 依赖于底层的操作系统的Mutex Lock(互斥锁)实现线程同步。这就是synchronized最初实现同步的方式,也是 JDK 6 之前 synchronized 效率低的原因。这种依赖于操作系统Mutex Lock实现的锁称为“重量级锁”,JDK 6 中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
- 目前锁共有4种状态,级别从低到高:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
如果使用 synchronized 给对象加重量级锁,该对象头的 Mark Word 中就被设置为指向Monitor对象的指针。
- 开始时 Monitor 中的 Owner 为 null;
- 当 Thread-2 执行 synchronized(obj){} 代码时就会将 Owner 设置为 Thread-2,加锁成功,