既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
2、实例数据:这部分主要是存放类的数据信息,父类的信息。
3、对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。一个空对象占8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。
在对象头中保存了锁标志位和指向 monitor 对象的起始地址,当 Monitor 被某个线程持有后,就会处于锁定状态,Owner 部分会指向持有 Monitor 对象的线程。另外 Monitor 中还有两个队列分别是EntryList和WaitList,主要是用来存放进入和等待获取锁的线程。如果线程进入,则得到当前对象锁,那么别的线程在该类所有对象上的任何操作都不能进行。
synchronized和Lock的区别:
- synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是类,是JDK层面的有丰富的API。
- synchronized会自动释放锁,而Lock必须手动释放锁,如果不释放就会发生死锁现象。
- synchronized无法获取锁的状态,Lock可以判断是否获取到了锁。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- synchronized是悲观锁,非公平锁,ReentrantLock可以控制是否是公平锁。
- synchronized锁中当一个线程阻塞,另一个线程会一直等待下去,Lock锁不一定会等待下去,tryLock()这个方法可以尝试获取锁。
- synchronized 可重入锁,不可中断,非公平锁,Lock锁可重入锁,可以中断也可以不中断,非公平(可以设置)。
- synchronized 适合少量的代码同步问题,Lock适合大量的同步代码。
- 两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的api。
volatile
一、volatile修饰符适用场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。
使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
1、volatile可以适合做多线程中的纯赋值操作,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全。
2、volatile可以作为刷新之前操作的触发器,状态标记量,我们可以将某个变量用volatile修饰,其他线程一旦发现该变量的值被修改后,触发获取到的该变量之前的操作都将是最新的且可见。flag被volatile修饰,充当了触发器,一旦值为被修改为true,那么此处对变量之前的操作对于后面的线程都是可见的。
二、作用
1、保证可见性:
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
2、不保证原子性:volatile 只能保证对单次读/写的原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
3、禁止指令重排:java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
三、volatile的原理和实现机制
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
四、特性
volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主 存中读取。
volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
volatile可以使得long和double的赋值是原子的。
volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
volatile和synchronized的区别
- volatile只能修饰实例变量和类变量;而synchronized可以修饰方法,以及代码块。
- volatile保证数据可见性,但是不保证原子性,不保证线程安全;而synchronized是一种互斥锁(排他)机制,可以保证线程安全。
- volatile用于禁止指令重排序,可以解决单例双重检查对象初始化代码执行乱序问题。
- volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
MySQL数据库中的各种锁
1、从数据操作的类型:读锁,写锁;
- 读锁:针对同一份数据,多个操作可以同时进行而不会相互影响。
- 写锁:当前操作没有完成前,它会阻断其他写锁和读锁。
- 读锁会阻塞写,但是不会阻塞读;
写锁会把读和写都堵塞;
2、锁按使用方式划分—悲观锁和乐观锁
- 悲观锁:对数据的冲突采取一种悲观的态度,无论是在数据读取,还是数据修改的过程都需要加锁。适合写入操作比较频繁的场景。表锁、行锁、读锁、写锁。
- 乐观锁:在数据读取时不会加锁,在数据修改时才会加锁。认为数据总是不会被更改,适合读取操作比较频繁的场景。使用版本号、使用时间戳。
3、锁按级别划分—共享锁(读锁)和排他锁(写锁)
- 共享锁(Share Lock),S锁,也叫读锁,用于所有的只读数据操作,共享锁是非独占的,允许多个并发事务读取其锁定的资源。共享锁会阻塞写,不会阻塞读。
- 互斥锁、排他锁(Exclusive Lock),X锁,也叫写锁,用于对数据进行写操作。如果一个事务对对象加了排他锁,其他事务就不能再给它加任何锁了。排它锁写和读都被阻塞。
4、锁按粒度划分—表锁和行锁
- 行锁是通过给索引上的索引项加锁来实现的,自动加锁,也可以显式加锁。开销大,加锁慢,会出现死锁,锁的粒度小,发生锁冲突的概率低,处理并发的能力强。
- 表锁,对表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;对表的写操作,则会堵塞其他用户对同一表的读和写操作。自动加锁,也可以显式加锁。开销小,加锁快,无死锁,锁粒度大,发生锁冲突的概率高,并发处理能力低。
5、死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去,此时系统处于死锁状态。等待锁超时、设置死锁检测。
6、公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
7、非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
MySQL主从复制原理
1、一主一从:(1)主服务器上面的任何修改都会通过自己的IO线程保存在二进制日志文件中(binary log)。
(2)从机上面也启动一个IO线程,通过配置好的用户名和密码,连接到主服务器上面请求读取二进制日志,然后把读取到的二进制日志写到本地的一个中继日志里面(Relay log)。
(3)从服务器上面同时开启一个SQL thread定时检查Realy log(二进制),如果发现有更新立即把更新的内容在本机的数据库在上面执行一遍。
2、一主多从:如果一主多从的话,这时主机要负责写又要负责为几个从库机提供二进制日志,此时可以稍作调整,将二进制日志只给某一从机,这一从机再将自己的二进制日志再发给其他从机。或者是干脆这个从机不记录只负责将二进制日志转发给其他从机,这样架构起来性能可能要好得多,而且数据之间的延时应该也稍微要好一些。
索引
索引(Index)可以简单理解为排好序的快速查找数据结构,索引文件的形式存储的磁盘上;提高数据检索的效率,降低数据库的IO成本,降低了CPU的消耗。实际上索引也是一张表,该表保存了主键与索引字段,并指向实体表的记录,所以索引表也是要占用空间的。
一、索引有哪些数据结构?
Hash、B+树
二、为什么哈希表、完全平衡二叉树、B树、B+树都可以优化查询,为何Mysql独独喜欢B+树?
1、哈希表的特点就是可以快速的精确查询,但是不支持范围查询,可能出现哈希冲突。Hash表适合等值查询的场景,就只有KV(Key,Value)的情况。
2、二叉树是有序的,所以是支持范围查询的,如果数据多了,树会很高,查询的成本就会随着树高的增加而增加。
3、B树中的一个节点可以存储多个元素,相对于完全平衡二叉树整体的树高降低了,磁盘IO效率提高了。
4、B+树是B树的升级版,把非叶子节点冗余一下,并且叶子节点之间用指针相连,会有指针指向下一个节点的叶子节点,提高了范围查找的效率。
Mysql选用B+树这种数据结构作为索引,可以提高查询索引时的磁盘IO效率,并且可以提高范围查询的效率,并且B+树里的元素也是有序的。
三、B+树中一个节点到底多大合适?
B+树中一个节点为一页或页的倍数最为合适。Mysql的基本存储结构是页,InnoDB存储引擎中默认每个页的大小为16KB。读取这个节点的时候会读出一页或页的倍数,这样不会造成资源的浪费。
四、索引的分类
- 单列索引:即一个索引只包含单个列,一个表可以有多个单列索引;
- 唯一索引:索引列的值必须唯一(控制该列不能有相同值),但允许有空值;
- 主键索引:索引列的值必须唯一(控制该列不能有相同值),不可以有空值,表中只有一个;
- 复合索引:一个索引包含多个列
- 覆盖索引:一个索引中包含所有需要查询字段的值,优化查询效率。
如何实现覆盖索引——将被查询的字段,建立到联合索引里去,
使用覆盖索引的好处——无需回表,少了一个索引树的扫描, - 聚簇索引:主键索引,并不是一种单独的索引类型,而是一种数据存储方式,数据行和相邻的键值紧凑的存储在一起,一个表只能有一个聚簇索引。聚簇索引,叶子结点存储行记录。
聚簇索引的优点:数据访问更快,聚簇索引将索引和数据保存在同一个索引树中,因此从聚簇索引中获取数据通常比在非聚簇索引中查找更快。 - 非聚簇索引:又称为普通索引,辅助索引,它的索引树的叶子节点存储的数据是主键值。因此对于非聚簇索引查询来说,它是无法通过一次扫描索引树然后定位行记录的,需要扫描两遍索引树。
- 回表查询,先通过普通索引定位到主键值,再通过聚簇索引定位到行记录。
五、注意的问题
1、单表查询中,范围查询不宜建立索引,固定值查询可以建立索引。
对于连接查询,左连接给右表建立索引,右连接给左表建立索引。
2、最左前缀匹配原则。MySQL会一直向右匹配直到遇到范围查询就停止匹配,范围查询后面的索引会失效。
3、尽量选择区分度高的列作为索引。
4、索引列不能参与计算,会导致索引失效而转向全表扫描。
5、尽可能的扩展索引,不要新建立索引。
数据库事务
1、事务是指一组最小的逻辑操作单元,里面有多个操作组成。组成事务的每一部分必须要同时提交成功,如果有一个操作失败,整个操作就回滚。
2、事务的ACID特性
(1)原子性(Atomicity)
原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。
(2)一致性(Consistency)
事务必须使数据库从一个一致性状态变换到另外一个一致性状态。
(3)隔离性(Isolation)
事务的隔离性是多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离。
(4)持久性(Durability)
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响。
一、隔离级别
1、读未提交(READ UNCOMMITTED):一个事务还没提交时,它做的变更就能被别的事务看到。
2、读提交(READ COMMITTED):一个事务提交之后,它做的变更才会被其他事务看到。
3、可重复读(REPEATABLE READ):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
4、串行化(SERIALIZABLE):对于同一行记录,“写”会加“写锁”,“读”会加“读锁”,当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
二、隔离级别解决了哪些问题:
1、脏读(dirty read):如果一个事务读到了另一个未提交事务修改过的数据。
2、不可重复读(non-repeatable read):如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值。
3、幻读(phantom read):如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来。
三、如何设置事务的隔离级别?
我们可以通过下边的语句修改事务的隔离级别:
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;//等级就是上面的几种
MVCC多版本并发控制机制
MVCC,Multi-Version Concurrency Control,多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问。
(1)MVCC是为了解决什么?
众所众知,在MySQL中,MyIASM使用的是表锁,InnoDB使用的是行锁。而InnoDB的事务分为四个隔离级别,其中默认的隔离级别REPEATABLE READ需要两个不同的事务相互之间不能影响,而且还支持并发,这点悲观锁是达不到的,所以REPEATABLE READ采用的就是乐观锁,而乐观锁的实现采用的就是MVCC,正是因为有了MVCC,才造就了InnoDB强大的事务处理能力。
(2)MVCC原理
MVCC的实现,通过保存在数据在某个时间点的快照来实现的。
在每行记录后面保存两个隐藏的列,一列保存了行的创建时间,另一列保存了行的过期时间(删除时间),这里存储的时间并不是实际的时间值,而是系统版本号,每开始一个新事物,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来与查询到的每行记录的版本号进行比较。
(3)MVCC特征
- 每行数据都存在一个版本,每次数据更新时都更新该版本。
- 修改时copy出当前版本进行修改,各个事务之间互不干扰。
- 保存时比较版本号,如果成功(commit),则覆盖原记录,失败则放弃copy(rollback回滚)。
(4)MVCC实现
1、redo log:重做日志,确保事务的持久性,防止在发生故障的时间点,尚有脏页未写入磁盘,在重启mysql服务的时候,根据redo log进行重做,从而达到事务的持久性。重做日志由两部分组成,一是内存中的重做日志缓冲区,因为重做日志缓冲区在内存中,所以她是易失的;另一个就是在磁盘上的重做日志文件,它是持久的。
2、undo log:回滚日志,保存了事务发生之前的数据的一个版本,可以用于回滚,同时可以提供多版本并发控制下的读(MVCC),也即非锁定读在MySQL中,恢复机制是通过回滚日志实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后在对数据库中的对应行进行写入。
3、bin log:二进制日志,用于复制,在主从复制中,从库利用主库上的bin log进行重播,实现主从同步。
实现
在每一行数据中额外保存两个隐藏的列:
- DATA_TRX_ID :记录最近一次修改(insert/update)本行记录的事务id,大小为6字节。
- DATA_ROLL_PTR:指向该行回滚段(rollback segment)的undo log record指针,大小为7字节。如果这一行记录被更新,则undo log record包含" 重建该行记录被更新之前内容"所必须的信息。InnoDB便是通过这个指针找到之前版本的数据,若改行记录上存储所有的旧版本,在undo中都通过链表的形式组织。
如果表没有主键,则还会有一个隐藏的主键列DB_ROW_ID。
- DB_ROW_ID:行标识(隐藏单调自增ID),大小为6字节,如果表没有主键,InnoDB会自动生成一个隐藏主键。
(5)可重复读隔离级别下,MVCC具体的操作流程
- SELECT:InnoDB只查找版本早于当前事务版本的数据行;行的删除版本,要么未定义,要么大于当前事务版本号,这样可以确保事务读取到的行在事务开始之前未被删除。
- INSERT:InnoDB为插入的每一行保存当前系统版本号作为行版本号。
- DELETE:InnoDB为删除的每一行保存当前系统版本号作为删除标识,标记为删除、而不是实际删除。
- UPDATE: InnoDB会把原来的行复制一份到回滚段中,保存当前系统版本号作为行版本号,同时,保存当前系统版本号到原来的行作为删除标识。
数据库操作
(1)DDL(Data Definition Language)用来定义数据库的库,表,列等。
(2)DML(Data Manipulation Language)用来定义数据库记录(增、删、改)
(3)DCL(Data Control Language)用来定义访问权限和安全级别;
MySQL数据库权限问题:root拥有所有权限(可以干任何事情),我们可以分配权限账户。
(4)DQL(Data Query Language)用来查询记录(数据)。
在MySQL中主要使用的是MyISAM引擎和InnDB引擎:
对比 | MyISAM | InnoDB |
---|---|---|
主外键 | 不支持 | 支持 |
事务 | 不支持 | 支持 |
行表锁 | 表锁 | 行锁 |
缓存 | 只缓存索引,不缓存真实数据 | 不仅缓存索引还要缓存真实数据 |
表空间 | 小 | 大 |
关注点 | 性能 | 事务 |
分组分页
分组查询:group by ,一般配合聚合函数使用查出的数据才有意义。
查询的字段:1.分组字段本身 2.聚合函数
分页查询:limit 起始索引,每页的索引数
起始索引=(页码-1)*每页的条数
三大范式
数据库的设计原则:尽量遵守三大范式。
(1)第一范式
要求表的每个字段必须是不可分割的独立单元。
student:name 张小凡|狗蛋 -- 违反第一范式
student:name 张小凡 old_name 狗娃 -- 符合第一范式
(2)第二范式
在第一范式的基础上,要求每张表只表达一个意思,表的每个字段都和表的主键有关系。
employee:员工编号 员工姓名 部门名称 订单名称 -- 违反第二范式
员工表:员工编号 员工姓名 部门名称
订单表:订单编号 订单名称 -- 符合第二范式
(3)第三范式
在第二范式基础,要求每张表的主键之外的其他字段都只能和主键有直接决定依赖关系。
员工表: 员工编号(主键) 员工姓名 部门编号 部门名 --符合第二范式,违反第三范式 (数据冗余高)
员工表:员工编号(主键) 员工姓名 部门编号 --符合第三范式(降低数据冗余)
部门表:部门编号 部门名
HashMap
一、数据结构
由数组和链表组合构成的数据结构。数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node。本身所有的位置都为null,在put插入的时候会根据key的hash去计算一个index值。
数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身就存在概率性,极端情况会hash到一个值上,那就形成了链表。
每一个节点都会保存自身的hash、key、value、以及下个节点。
HashMap的扩容机制:数组容量是有限的,数据多次插入的,到达一定的数量就会进行扩容,也就是resize。
二、怎么扩容?
扩容:创建一个新的Entry空数组,长度是原数组的2倍。
ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
因为长度扩大以后,Hash的规则也随之改变,所以要重新hash。
三、什么时候resize呢?
Capacity:HashMap当前长度。
LoadFactor:负载因子,默认值0.75f。
负载因子较小,发生哈希冲突的概率也会小,但是每次元素个数达到数组长度一半时就要进行扩容,这样岂不是造成了很大的空间浪费。
负载因子较大,空间利用率大大提高了,哈希冲突发生的概率肯定会增大,并且链表长度和红黑树的高度肯定会变大,增删改查的效率也会很低。
HashMap的默认初始化长度是16。位与运算比算数计算的效率高了很多,之所以选择16,是为了服务将Key映射到index的算法。
Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表,根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
计算索引的方法index = HashCode(Key) & (Length- 1),0不能出现在index的二进制编码数里面,意思就是要全部为1,二进制编码全为1的数加1一定是一个2的整次幂的数。这也是为什么数组长度在与hash做与运算的时候要减1的原因。
put方法:通过hash方法求出key的hash值(扰动函数)
get方法:返回为key的元素的结点的value值
remove方法:返回要删除的元素的value
jdk1.8中的hashmap相对于jdk1.7中的有什么不同?
(1)jdk1.7是数组+链表的结构,jdk1.8中是数组+链表+红黑树的结构;
(2)jdk1.7链表中数据插入方式是头插法,插入元素放到桶中,原来的元素作为插入元素的后继元素。而jdk1.8中采用的是尾插法,直接放到链表的尾部。
(3)jdk1.7在扩容时需要对元素进行重新哈希以确定元素在新数组中的位置,而jdk1.8中不需要重新哈希,要么存储在和原数组相同的位置,要么存储在原数组位置+原数组长度的位置。
(4)jdk1.7中是先判断是否需要扩容,再插入,而jdk1.8是先插入,再扩容。
改进后的优点
(1)减少哈希冲突,提高操作效率,
(2)因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。
HashMap 和 Hashtable 的区别
(1)HashMap是线程不安全的,Hashtable是线程安全的,每个方法都加了synchronized。
(2)HashMap的效率高于Hashtable,因为Hashtable加了synchronized会发生阻塞,严重影响了效率。
(3)HashMap中允许有null键(一个)null值(多个),Hashtable不允许有。Hashtable在我们put 空值的时候会直接抛空指针异常,安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。
- 实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。Dictionary 是 JDK 1.0 添加的,貌似没人用过这个,我也没用过。
- 初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。
- 扩容机制不同:当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
- 迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。
所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,而 Hashtable 则不会。
ConcurrentHashMap
HashMap在多线程环境下存在线程安全问题,那你一般都是怎么处理这种情况的?
- 使用Collections.synchronizedMap(Map)创建线程安全的map集合;
- Hashtable
- ConcurrentHashMap
ConcurrentHashMap 底层是基于 数组 + 链表 组成的。
在JDK1.7版本中,采用分段锁的思想,ConcurrentHashMap的数据结构是由一个Segment数组和多个HashEntry组成,Segment 是 ConcurrentHashMap 的一个内部类,HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。真正实现线程安全是采用ReentrantLock来实现。
缺点:无论是put还是get元素都需要进行两次hash,效率慢,并且锁的粒度大。在JDK 1.8中,ConcurrentHashMap采用CAS+Synchronized加锁,锁的粒度减小。
原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。他先定位到Segment,然后再进行put操作。
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
尝试自旋获取锁。如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。
get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
存在问题:基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低。
jdk1.8他的数据结构是怎么样子的呢?
其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。
根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
ConcurrentHashMap在1.8数据的插入put过程?
- 如果没有初始化就先调用initTable()方法来进行初始化过程。
- 如果没有hash冲突就直接CAS插入。
- 如果当前哈希表正在进行扩容操作就先进行扩容。
- 如果存在hash冲突,就加锁(Synchronized)来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入。
- 最后一个如果Hash冲突时会形成Node链表,在链表长度超过8,Node数组超过64时会将链表结构转换为红黑树的结构,break再一次进入循环。
- 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容。
ConcurrentHashMap的get操作又是怎么样子的呢?
根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。如果是红黑树那就按照树的方式获取值。就不满足那就按照链表的方式遍历获取值。
CAS
CAS(Compare And Swap 比较并且替换)CAS操作包含三个操作数——内存位置(V),期望值(A)和新值(B),如果内存中的值和期望值匹配,那么处理器会自动将位置值更新为新值。是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。整个比较并替换的操作是一个原子操作。
1、CAS 是怎么实现线程安全的?
线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
在线程开启的时候,会从主存中给每个线程拷贝一个变量副本到线程各自的运行环境中,CAS算法中包含三个参数(V,E,N),V表示要更新的变量(也就是从主存中拷贝过来的值)、E表示预期的值、N表示新值。
2、优点
这个算法相对synchronized是比较“乐观的”,它不会像synchronized一样,当一个线程访问共享数据的时候,别的线程都在阻塞。synchronized不管是否有线程冲突都会进行加锁。由于CAS是非阻塞的,它死锁问题天生免疫,并且线程间的相互影响也非常小,更重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,所以它要比锁的方式拥有更优越的性能。
3、存在什么问题呢?
- CUP开销:CAS操作长时间不成功的话,会导致一直自旋,相当于死循环了,CPU的压力会很大。
- ABA问题:线程1读取了数据A,线程2读取了数据A,线程2通过CAS比较,发现值是A没错,可以把数据A改成数据B,线程3读取了数据B,线程3通过CAS比较,发现数据是B没错,可以把数据B改成了数据A,线程1通过CAS比较,发现数据还是A没变,就写成了自己要改的值。值被改变了,线程1却没有办法发现。
- 只能保证一个共享变量原子操作:CAS操作单个共享变量的时候可以保证原子操作,对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性,JDK 5之后 AtomicReference可以用来保证对象之间的原子性,就可以把多个对象放入CAS中操作。
4、那开发过程中ABA你们是怎么保证的?
- 加标志位,例如搞个自增的字段,操作一次就自增加一。
- 加时间戳,比较时间戳的值。
- 加版本号,判断原来的值和版本号是否匹配。
ReentrantLock
AQS:(AbstractQueuedSynchronizer)也就是队列同步器,这是实现 ReentrantLock 的基础。AQS 有一个 state 标记位,值为1 时表示有线程占用,其他线程需要进入到同步队列等待,同步队列是一个双向链表。当获得锁的线程需要等待某个条件时,会进入 condition 的等待队列,等待队列可以有多个。当 condition 条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。
ReentrantLock 就是基于 AQS 实现的,ReentrantLock 内部有公平锁和非公平锁两种实现,差别就在于新来的线程是否比已经在同步队列中的等待线程更早获得锁。和 ReentrantLock 实现方式类似,Semaphore 也是基于 AQS 的,差别在于 ReentrantLock 是独占锁,Semaphore 是共享锁。
ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。
ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
线程、线程池
创建线程
进程是拥有资源的基本单位,线程是CPU调度的基本单位。
1、继承Thread类
2、实现Runnable接口
3、实现 Callable 接口。 相较于实现 Runnable 接口的方式,方法可以有返回值,并且可以抛出异常。
4、通过线程池创建
线程的状态
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
Lock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
线程、线程池
创建线程
进程是拥有资源的基本单位,线程是CPU调度的基本单位。
1、继承Thread类
2、实现Runnable接口
3、实现 Callable 接口。 相较于实现 Runnable 接口的方式,方法可以有返回值,并且可以抛出异常。
4、通过线程池创建
线程的状态
[外链图片转存中…(img-SFRQsFOc-1715517465930)]
[外链图片转存中…(img-Rtft15BW-1715517465930)]
[外链图片转存中…(img-sO7bC3eB-1715517465931)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新