java基础-并发
我是做后端开发的,游戏后端并发问题是个永恒的问题,而相对来说web开发一类并发问题并没有那么多和那么重要。
当然也是和游戏类型有关系,做了多年SLG游戏,大量的多玩家数据交互问题太多了,地图的争夺、战斗、阵营或军团的数据交换等含有大量的并发问题。而相对来说RPG的游戏除了交易系统、战斗系统等有并发读写其他的基本都是单玩家数据操作。而休闲游戏并发就更少了。这也是各种游戏的不同之处。而作为高级开发以上级别的技术人员,并发肯定是很大的考点。
本文将讲述经常问到的并发问题,主要就是各种锁和锁的实现原理、效率与选取原则。还有经常被问到的volatile的使用。
锁
大多都挂在门上,也有其他地方有比如窗户、箱子等,都是不想让没有钥匙的人进去的。
程序锁同样道理的,加上锁后不希望其他的程序进入执行,只有当锁释放了才可以进入执行。
锁的作用在单线程的场所完全用不上,只有多线程并发环境才有用,经典的数据库问题银行转账的那个例子,若不加事务会有多种问题产生,都是两个线程并行运行而并行碰撞时间点不同而导致的,事务也就是使用锁来实现的,加上锁后执行顺序就变成串行的,这样就会一个线程执行完后,另一个线程再执行,就不会有并行的问题了。所以本来就单线程的场景不用加锁,就完全避开了这个问题。
程序锁就是解决多线程并行运行时读写共享资源产生的冲突之用。不加锁可能没有问题(出问题都是无法解决而且很多还很难理解),但是加锁肯定没有问题。
锁的分类
乐观锁、悲观锁
独享锁、共享锁
公平锁、非公平锁
互斥锁、读写锁
可重入锁
分段锁
锁存在的问题
1.效率问题,本来是并行运行的,加了锁就变成串行运行,另外维护锁的状态、队列也需要空间和计算资源,这些都会导致一些性能开销都会导致效率降低。
2.增加编码难度,新手面对锁就会一脑袋问号,该不该加锁?加啥锁?锁啥?在哪里开始加锁在哪里解锁?嵌套锁咋办?。。。。。。所以锁要么都封装在底层框架中,要么就是高级开发写好代码框架新手往里面填代码,要么就是提前做好规约讲述好前面那些问题,新手才敢加锁,不然就算加了锁,估计解决问题的事件比做规约时间还长。
3.死锁问题,前面说锁增加的代码难度,而代码中锁没有做好最差的两个程序问题就是加了锁没解锁,导致锁死,或者是嵌套锁导致互相等锁而死锁。这两个问题都需要框架或者规约来解决。
java的锁
java提供了多种锁的方式,底层就提供了synchronized同步的实现,jdk也实现了可重入锁、CAS的乐观锁、读写锁等。其他的例如ConcurrentHashMap中使用分段锁。
这些锁几乎涵盖了前面锁分类中所有的分类了。
synchronized的实现
同步,jvm底层实现的,我们只有看C源码才能看到它是如何实现的,不是用java代码实现,并且在对象的内存结构上已经为它的实现做了一些数据准备。所以对synchronized我们没有什么可以修改或者优化的,我们做的只有了解它,知道它啥时候可以用,为啥这么用和效率是否更高效。
1.对象头信息,java基础-jvm内存一节见到过对象头中存有锁的信息,就是为了给synchronized使用的,这些信息可以判断对象是否持有同步锁、锁阶段、锁时间等。
2.Monitor Record线程私有的,而且有一个全局的Monitor Record,用于维护当前哪些线程持有同步锁,下一个进入锁的是哪个(类似于锁实现的AQS)。
3.锁升级,偏向锁->轻量级锁->重量级锁。
以上三个概念就是synchronized的实现基础,所以它的运行过程就是:程序运行到synchronized代码段,查询加锁的对象头的锁信息:
a.没有锁,加上偏向锁并继续运行,偏向锁其实就是一个标识,标识对象有锁,但是没有任何其他开销。
b.偏向锁,升级为轻量级锁,并开始进行自旋获取锁。
c.轻量级锁,自旋到一定次数后,升级为重量级锁。
d.重量级锁,重量级锁可以理解为和java的可重入锁实现类似,底层使用Monitor Record维护一条等锁的队列。
以上过程可以看出,当竞争不频繁的时候,它是几乎无任何开销的,就算是竞争比较多了,java后期多次优化后效率也可以和可重入锁相当。那么问题来了,是不是就不要可重入锁了?其实不然,synchronized是java底层实现,只能对方法和代码块添加而且只能添加一个对象,这就是锁粒度不够灵活,而且加锁解锁的方式也是固定不够灵活。而且synchronized是不公平锁没办法改变。
可重入锁
除了synchronized其次常用的也就是可重入锁ReentrantLock了。锁都类似,只不过它是编程的方式实现的锁机制。是使用AQS实现的锁。
AQS即AbstractQueuedSynchronizer抽象队列同步器,各种锁、线程池都是依赖它实现的,其维护了当前的状态、获取锁的节点、等待锁的队列。
简单原理就是加锁的时候判断状态,若可以获得锁就直接获得锁,并且添加一个节点记录当前线程获得锁;再有其他线程尝试加锁,就需要阻塞住线程并进入等待队列中,直到持有锁的线程调用解锁,然后查看队列中是否有等待的线程节点进行唤醒。
AQS中对线程的精确管理使用的是LockSupport进行管理。
可重入锁更加灵活,灵活表现在锁粒度可控、锁的位置可控、可以设置是否是公平锁,但是灵活度也增加的编码的难度,例如解锁错误、不解锁、死锁是经常发生的问题。所以编码需要做很多规约,例如:
1.加锁后用try块再finally中进行解锁。
2.加锁对象与解锁对象必须一致。
3.嵌套锁同类型对象列表加锁需要排序,不同类型对象嵌套加锁需要提前订好加锁顺序。
volatile关键词的效果
很多问题会引出volatile关键词的使用,但是这个关键词的使用的问题只有一个,就是对共享资源的原子操作问题。
其实它的目的是为了解决共享资源进行原子操作的,但是没有完全解决,大多数时候还是需要Unsafe类对内存直接进行CAS操作解决。
这个关键词的作用有两个:1.内存修改的可见性,2.禁止指令重排。
不会有更多的问题,不过它延伸出来的问题可就比较多了。比如CAS算法、单例的实现、锁的状态为啥加volatile关键字等。
多线程相关问题
线程池是必考题,会在java基础-池化一节具体讲解
线程和进程的区别,偶尔会有问到。进程是操作系统资源分配最小单元,线程是程序运行最小单元,多个线程跑在一个进程中是包含关系。
线程的阻塞问题,sleep睡一会,过会就醒了;wait与notify,wait就是暂停当前线程的运行,等待有notify或者notifyAll进行唤醒,它会释放synchronized的当前锁的;join阻塞当前线程,知道调用该方法的线程执行完;LockSupport是一个线程阻塞的支持工具类,相比wait可以更精确管理让哪个线程唤醒。
ThreadLocal问题,作用不用多说,原理是在每个线程中维护一个单独Map来维护自己线程的一些变量使用。面试问题大多集中在它的内存泄露上,即ThreadLocalMap的Entry是软引用的,当ThreadLocal没有硬引用的时候,任何的GC都会把它回收了,但是value引用的对象还存在强引用,这个对象就会一直存在而没办法被使用,它就是漏掉的垃圾,所以尽量使用完后都要调用remove方法进行移除。
线程池最佳线程数量
统一的计算公式为 线程数 = cpu核心数量 x (1 + 阻塞时间/实际计算时间)
线程数主要看的就是阻塞时间,而阻塞时间基本就是io等待的时间。所以结论如下:
计算密集型业务,阻塞时间基本为零,所以 线程数 = cpu核心数。另外有一种说法就是 线程数 = cpu核心数 + 1,就是当有一个任务发生页错误的时候,有一个冗余任务可以替换上来执行。
io密集型业务,根据预估或者测试得到的阻塞时间确定线程数,例如 阻塞时间=实际计算时间 也就是有一半时间阻塞住了。那么就是 线程数 = 2*cpu核心数。
以上是理论值,由于阻塞时间不是很好求得,可以通过预估的办法来进行计算,然后通过压力测试等手段来进行调整。
java超时控制
这个问题考察两个方面,一个是多线程的应用功底,是否使用过或者理解join,concurrent包中的future类等,另外一个就是考察对api的思考,很多api特别是web类的都有超时机制,这个超时是怎么实现的等。
1.主线程子线程方法。就是主线程控制时间,主线程中包含子线程,子线程start后适应join方法并设置好时间则主线程阻塞,到达设置好的时间时主线程被唤醒若子线程还没有处理完成则做超时处理。
2.使用concurrent中的future类实现,使用Future类包住子线程,并且用该类设置超时时间。
3.第三方轮训,即任务执行之后放在一个轮训队列中,并且任务要设置超时时间戳和超时回调函数,一个线程定时遍历该队列,当有任务超时了,则移出队列并调用超时回调函数。
参考资料
java中synchronized的底层实现
Java中的锁分类
浅谈JAVA中的线程阻塞方法
Java并发(八)计算线程池最佳线程数

浙公网安备 33010602011771号