一、进程与线程
进程:是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。
线程:是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的 资源。
虽然系统是把资源分给进程,但是CPU很特殊,是被分配到线程的,所以线程是CPU分配的基本单位。
一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。
二、线程的生命周期
一般来说,线程可以归纳为5种状态,依次是 新建状态、就绪状态、运行状态、阻塞状态及死亡状态。
- 新建状态:及我们刚开始创建一个线程,如:MyThread th = new MyThread();
- 就绪状态:新创建的线程被start()后即进入就绪状态,等待CPU调度(误区:线程并不是start后就立即运行的) th.start();
- 运行状态:CPU调度就绪状态的线程后线程即进入运行状态执行程序块(线程只能通过就绪状态进入运行状态)
- 死亡状态:运行状态的线程正常结束或者是异常退出后,线程即进入死亡状态。死亡状态的线程不可再次被调用
- 阻塞状态:处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。运行中的线程有多种情况可导致进入阻塞状态,常见的如sleep休眠、获取锁失败等等
三、线程的优先级
Java线程可以配置优先级,线程优先级高的,有更高几率被优先执行,线程的优先级可以通过thread.setPriority(n)来设定,n的取值范围从1到10的整数,10表示优先级最高,1表示优先级最低,5表示普通优先级,当我们没有对线程设定优先级时,线程默认是普通优先级。
线程设定优先级后并不是完全按照优先级来进行执行顺序,线程的执行顺序取决于CPU调度程序来决定,调度到的线程才会被执行。在实际编程中,不要依赖于线程的优先级来达到控制某些效果,线程的执行顺序并不完全取决于线程优先级
四:线程的两种常见创建方式
1.实现Runnable接口
public class ThreadDemo{
public static int num = 100;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
new ThreadDemo().test();
}
public void test(){
Thread runDemo1 = new Thread(new RunDemo());
Thread runDemo2 = new Thread(new RunDemo());
Thread runDemo3 = new Thread(new RunDemo());
Thread runDemo4 = new Thread(new RunDemo());
runDemo1.setName("吃货1号");
runDemo2.setName("吃货2号");
runDemo3.setName("吃货3号");
runDemo4.setName("吃货4号");
runDemo1.start();
runDemo2.start();
runDemo3.start();
runDemo4.start();
}
class RunDemo implements Runnable {
@Override
public void run() {
while(ThreadDemo.num > 0){
lock.lock();
// synchronized (RunDemo.class){
if(ThreadDemo.num <= 0){
System.out.println(Thread.currentThread().getName()+ "说吃完了 靠!");
}else{
System.out.println(Thread.currentThread().getName()+ "说还有:"+ThreadDemo.num+"我吃一口");
ThreadDemo.num -= 1;
}
// }
lock.unlock();
}
}
}
}
2、继承Thread类
public class ThreadDemo{
public static int num = 100;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
new ThreadDemo().test();
}
public void test(){
ThreadTest threadTest1 = new ThreadTest();
ThreadTest threadTest2 = new ThreadTest();
ThreadTest threadTest3 = new ThreadTest();
threadTest1.setName("吃货5号");
threadTest2.setName("吃货6号");
threadTest3.setName("吃货7号");
threadTest1.start();
threadTest2.start();
threadTest3.start();
}
class ThreadTest extends Thread {
@Override
public void run() {
while(ThreadDemo.num > 0){
lock.lock();
// synchronized (RunDemo.class){
if(ThreadDemo.num <= 0){
System.out.println(Thread.currentThread().getName()+ "说吃完了 靠!");
}else{
System.out.println(Thread.currentThread().getName()+ "说还有:"+ThreadDemo.num+"我吃一口");
ThreadDemo.num -= 1;
}
// }
lock.unlock();
}
}
}
}
四:线程安全
通过以上创建线程的代码分析,多个线程同时操作一个共同的对象时容易发生抢占资源的行为。就可能出现同样的数据状态被重复读取多次的情况。比如以下情况看,在不加锁进行限制的时候,资源100被重复使用了两次。
因此为了应对这种情况,JAVA中使用Lock锁以及同步代码块synchronized同步代码块来处理。当一个线程进入该区域时,其他线程等待上一个线程执行结束释放资源后,再进入区域执行内容。
五:乐观锁和悲观锁
1.悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
悲观锁机制存在以下问题:
1. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
2. 一个线程持有锁会导致其它所有需要此锁的线程挂起。
3. 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
2.乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
3.适用场景
乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
总结
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
3.乐观锁常见实现方式
- 版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
- 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
- 在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
- 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
- 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。
- CAS(Compare-and-Swap,即比较并替换)算法实现。
即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
乐观锁的缺点
1.ABA问题
假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:
- (1)线程1读取内存中数据为A;
- (2)线程2将该数据修改为B;
- (3)线程2将该数据修改为A;
- (4)线程1对数据进行CAS操作
在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。
在AtomicInteger的例子中,ABA似乎没有什么危害。
但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。
对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;
在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。
JDK 1.5 以后的 AtomicStampedReference 类
就提供了此种能力,其中的 compareAndSet 方法
就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
通过AtomicInteger的源码,了解下它的自增操作getAndIncrement()是如何实现的
public class AtomicInteger extends Number implements java.io.Serializable {
//存储整数值,volatile保证可视性
private volatile int value;
//Unsafe用于实现对底层资源的访问
private static final Unsafe unsafe = Unsafe.getUnsafe();
//valueOffset是value在内存中的偏移量
private static final long valueOffset;
//通过Unsafe获得valueOffset
static {
try {
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
}
源码分析说明如下:
- getAndIncrement()实现的自增操作是自旋CAS操作:在循环中进行compareAndSet,如果执行成功则退出,否则一直执行。
- 其中compareAndSet是CAS操作的核心,它是利用Unsafe对象实现的。
- Unsafe又是何许人也呢?
Unsafe是用来帮助Java访问操作系统底层资源的类,通过Unsafe,Java具有了底层操作能力,可以提升运行效率;
强大的底层资源操作能力也带来了安全隐患(类的名字Unsafe也在提醒我们这一点),因此正常情况下用户无法使用。
AtomicInteger在这里使用了Unsafe提供的CAS功能。
- valueOffset可以理解为value在内存中的偏移量,对应了CAS三个操作数(V/A/B)中的V;偏移量的获得也是通过Unsafe实现的。
- value域的volatile修饰符:Java并发编程要保证线程安全,需要保证原子性、可视性和有序性;
CAS操作可以保证原子性,而volatile可以保证可视性和一定程度的有序性;
在AtomicInteger中,volatile和CAS一起保证了线程安全性。
这里讲一下volatile和synchronized的区别
volatile修饰的变量每次被线程访问时,都强迫线程从主内存中重读该变量的最新值,而当该变量发生修改变化时,也会强迫线程将最新的值刷新回主内存中。这样一来,不同的线程都能及时的看到该变量的最新值。
但是volatile不能保证变量更改的原子性:
比 如number++,这个操作实际上是三个操作的集合(读取number,number加1,将新的值写回number),volatile只能保证每一 步的操作对所有线程是可见的,但是假如两个线程都需要执行number++,那么这一共6个操作集合,之间是可能会交叉执行的,那么最后导致number 的结果可能会不是所期望的
所以对于number++这种非原子性操作,推荐用synchronized:
synchronized(this){ number++; }
- synchronized和volatile比较
- volatile不需要同步操作,所以效率更高,不会阻塞线程,但是适用情况比较窄
- volatile读变量相当于加锁(即进入synchronized代码块),而写变量相当于解锁(退出synchronized代码块)
- synchronized既能保证共享变量可见性,也可以保证锁内操作的原子性;volatile只能保证可见性
2. 循环时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
3. 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类
来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类
把多个共享变量合并成一个共享变量来操作。
参考资料:
https://www.cnblogs.com/renhui/p/9755789.html
https://www.cnblogs.com/qjjazry/p/6581568.html
https://zhuanlan.zhihu.com/p/95296289