线程安全与锁

目前CPU的运算速度已经达到百亿次每秒, 甚至更高的量级, 在现实场景中, 为了提高生产率和高效的完成任务, 处处均采用多线程和并发的运作方式

并发是指在某个时间段内, 多任务交替处理的能力, 所谓不患寡而患不均, 每个CPU不可能只顾着执行某个进程, 让其他线程进入等待状态, 所以, CPU把可执行时间均匀的分成若干份, 每个线程执行一段时间后, 记录当前的工作状态, 释放相关的执行资源并进入等待状态, 让其他进程抢占CPU资源
并行是指同时处理多任务的能力, 目前, CPU已经发展为多核, 可以同时执行多个互不依赖的指令及执行块, 并发和并行两个概念非常容易混淆, 它们的核心区别在于进程是否同时执行

并发环境下, 由于程序的封闭性被打破, 出现以下特点:

  • 1).并发程序之间有相互制约的关系, 直接制约体现为一个程序需要另一个程序的计算结果, 间接制约体现为多个程序竞争共享资源, 如处理器,缓冲区等
  • 2).并发程序的执行过程是断断续续的, 程序需要记忆现场指令及执行点
  • 3).当并发数设置合理并且CPU拥有足够的处理能力时, 并发会提高程序的运行效率

(一).线程安全

线程是CPU调度和分派的基本单位, 为了更充分的利用CPU资源, 一般都会使用多线程进行处理, 多线程的作用是提高任务的平均执行速度, 但是会导致程序可理解性变差, 编程难度加大

线程可以拥有自己的操作栈, 程序计数器, 局部变量表等资源, 它与同一进程内的其他线程共享该进程的所有资源, 线程在生命周期内存在多种状态, 有NEW(新建状态), RUNNABLE(就绪状态), RUNNING(运行状态), BLOCKED(阻塞状态), DEAD(终止状态)五种状态

如图:
在这里插入图片描述
1).NEW, 即新建状态, 是线程被创建且未启动的状态, 创建线程的方式有三种:

  • 第一种是继承自Thread类
  • 第二种是实现Runnable接口
  • 第三种是实现Callable接口

相比第一种, 推荐第二种方式, 因为继承自Thread类往往不符合里氏替换原则, 而实现Runnable接口可以使编程更加灵活, 对外暴露的细节比较少, 让使用者专注于实现线程的run()方法上
Callable与Runnable有两点不同:

  • 第一, 可以通过call()获得返回值, 前两种方式都有一个共同的缺陷, 即在任务执行完成后, 无法直接获取执行结果, 需要借助共享变量等获取, 而Callable和Future则很好的解决了这个问题
  • 第二, call()可以抛出异常,而Runnable只有通过setDefaultUncaughtExceptionHandler()的方式才能在主线程中捕捉到子线程异常

2).RUNNABLE,即就绪状态, 是调用start()之后运行之前的状态, 线程start()()不能被多次调用, 否则会抛出IllegalStateException异常

3).RUNNING,即运行状态, 是run()正在执行时线程的状态, 线程可能会由于某些因素而退出RUNNING, 如时间, 异常, 锁, 调度等

4).BLOCKED,即阻塞状态, 进入此状态,有以下种情况:

  • 同步阻塞:锁被其他线程占用
  • 主动阻塞:调用Thread的某些方法,主动让出CPU执行权,比如sleep(),join()等
  • 等待阻塞:执行了wait()

5).DEAD,即终止状态,是run()执行结束,或因异常退出后的状态,此状态不可逆转

为了保证线程安全,在多个线程并发的竞争共享资源时,通常采用同步机制协调各个线程的执行,以确保得到正确的结果

线程安全问题只在多线程环境下才出现, 单线程串行执行不存在此问题, 保证高并发场景下的线程安全, 可以从以下四个维度考量:
1).数据单线程内可见, 单线程总是安全的, 通过限制数据仅在单线程内可见, 可以避免数据被其他线程篡改, 最典型的就是线程局部变量, 它存储在独立虚拟机栈帧的局部变量表中, 与其他线程毫无瓜葛, ThreadLocal就是采用这种方式来实现线程安全的

2).只读对象, 只读对象总是安全的, 它的特性是允许复制, 拒绝写入, 最典型的只读对象有String,Integer等, 一个对象想要拒绝任何写入, 必须要满足以下条件: 使用final关键字修饰类, 避免被继承, 使用private final关键字避免属性被中途修改, 没有任何更新方法, 返回值不能可变对象为引用

3).线程安全类, 某些线程安全类的内部有非常明确的线程安全机制, 比如StringBuffier就是一个线程安全类, 它采用synchronized关键字来修饰相关方法

4).同步与锁机制, 如果想要对某个对象进行并发更新操作, 但又不属于上述三类, 需要开发工程师在代码中实现安全的同步机制

线程安全的核心理念就是"要么只读, 要么加锁", 合理利用好JDK提供的并发包, java并发包(java.util.concurrent, JUC)主要分成以下几个类族:
1).线程同步类, 这些类是线程间的协调更加容易, 支持了更加丰富的线程协调场景, 逐步淘汰了使用Object的wait()和notify()进行同步的方式, 主要代表为CountDownLatch, Semaphore, CyclicBarrier等

2).并发集合类, 集合并发操作的要求是执行速度快, 提取数据准, 最著名的类非ConcurrentHashMap莫属, 它不断的优化, 由刚开始的锁分段到后来的CAS, 不断的提升并发性能, 其他还有ConcurrentSkipListMap, CopyOnWriteArrayList, BlockingQueue等

3).线程管理类, 虽然Thread和ThreadLocal在JDK1.0就已经引入, 但是真正把Thread发扬光大的是线程池, 根据实际场景的需要, 提供了多种创建线程池的快捷方式, 如使用Executors静态工厂或者使用ThreadPoolExecutor等, 另外, 通过ScheduledExecutorService来执行定时任务

4).锁相关类, 锁以Lock接口为核心, 派生出在一些实际场景中进行互斥操作的锁相关类, 最有名的是ReentrantLock, 锁的很多概念在弱化, 是因为锁的实现在各种场景中已经通过类库封装进去了

(二).什么是锁

现代的密码锁, 指纹锁, 虹膜识别锁等, 计算机的锁也是从开始的悲观锁, 发展到后来的乐观锁, 偏向锁, 分段锁等, 锁主要提供了两种特性:互斥性和不可见性

1).用并发包中的锁类
并发包的类族中, Lock是JUC包的顶层接口, 它的实现逻辑并未用到synchronized, 而是利用了volatile的可见性
Lock的继承关系:
在这里插入图片描述
图为Lock的继承类图, ReentrantLock对于Lock接口的实现主要依赖了Sync, 而Sync继承了AbstractQueuedSynchronizer(AQS), 它是JUC包实现同步的基础工具, 在AQS中, 定义了一个volatile int state变量作为共享资源, 如果现场获取资源失败, 则进入同步FIFO队列中等待, 如果成功获取资源就执行临界区代码, 执行完释放资源时, 会通知同步队列中的等待线程来获取资源后出队并执行

AQS是抽象类, 内置自旋锁实现的同步队列, 封装入队和出队的操作, 提供独占, 共享, 中断等特性的方法, AQS的子类可以定义不同的资源实现不同性质的方法, 比如可重入锁ReentrantLock, 定义state为0时可以获取资源并置为1, 若已获得资源, state不断加1, 在释放资源时state减1, 直至为0, CountDownLatch初始时定义了资源总量state=count, countDown()不断将state减1, 当state=0时才能获得锁, 释放后state就一直为0, 所有线程调用await()都不会等待, 所以CountDownLatch是一次性的, 用完后如果再想用就只能重新创建一个, 如果希望循环使用, 推荐使用基于ReentrantLock实现的CyclicBarrier, Semaphore与CountDownLatch略有不同, 同样也是定义了资源总量state=permits, 当state>0时就能获的锁, 并将state减1, 当state=0时只能等待其他线程释放锁, 当释放锁时state加1, 其他等待线程又能获得这个锁, 当Semphore的permits定义了为1时, 就是互斥锁, 当permits>1就是共享锁
JDK8提出了一个新的锁: StampedLock, 改进了读写锁ReentrantReadWriteLock

2).利用同步代码块
同步代码块一般使用java的synchronized关键字来实现, 由两种方式对方法进行加锁操作, 第一, 在方法签名处加synchronized关键字, 第二, 使用synchronized(对象或类)进行同步,这里的原则是锁的范围尽可能的小,锁的时间尽可能短,即能锁对象,不锁类,能锁代码块,不锁方法,synchronized锁特性由JVM负责实现,特别是偏向锁的实现
JVM底层是通过监视锁来实现synchronized同步的,监视锁即monitor,是每个对象与生俱来的一个隐藏字段,使用synchronized时,JVM会根据synchronized的当前使用环境,找到对应对象的monitor,在根据monitor的状态进行加,解锁的判断,例如,线程在进入同步方法或代码块时,会获取改方法或代码块所属对象的monitor,进行加锁判断,如果成功加锁就成为该monitor的唯一持有者,monitor在被释放前,不能再被其他线程获取
方法元信息中会使用ACC_SYNCHRONIZED标识该方法是一个同步方法,同步代码块中会使用monitorentermonitorexit两个字节码指令获取和释放monitor,如果使用monitorenter进入时monitor为0,表示该线程可以持有monitor后续代码,并将monitor加1,如果当前线程已经持有了monitor,那么monitor继续加1,如果monitor非0,其他线程就会进入阻塞状态,偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值