开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Android Native 内存问题的终极武器——MT

发表于 2021-09-30

本文分析基于Android S (12)

前言

汇编、C和C++本质上都是内存不安全的语言,因此开发者的无心之过可能会导致非法访问、内存踩踏等多种问题。这些内存问题一方面会影响用户的使用体验(进程崩溃、系统重启等);另一方面也会被黑客利用,增加入侵的机会。所以内存问题不仅是稳定性的问题,也是安全性的问题。当然,如果考虑到后期安全补丁带来的升级影响,它或许也能算得上是一个经济问题。

Android的native世界基本由C++语言构成(也包含少量的C、汇编和S上引入的Rust),其代码量甚至占到了平台总代码量的70%。因此,Google在这些年里研发了各种工具,目的就是为了高效地发现和解决各种内存问题。从最早期采用开源世界里的Valgrind工具,到Android N(7)上自主研发的Address Sanitizer(ASan),Android Q(10)上引入的Hardware Address Sanitizer(HWASan),和最新Android S(12)上引入的ARM Memory Tagging Extension(MTE)。

工具的演进过程.jpg

ASan和HWASan由Google自主开发,MTE由Google和ARM联合开发。尽管这三种工具的内部实现各有不同,但它们底层的思想其实是一致的。简而言之,这些工具的工作过程只有两步:

  1. 在内存分配时为它生成一个独特的tag(标签)。
  2. 在内存访问时去检测tag是否合规。

在这个框架的基础上,每个工具的具体实现其实都在回答下述三个问题:

  1. tag如何生成?
  2. tag如何存储?
  3. tag是否合规的判据是什么?

我们以ASan为例,回答上述三个问题。

  1. tag由单字节表示,可以反映8个真实内存字节的状态。生成时根据该块内存的实际状态选取tag值,因此tag值具有事先规定的含义。0x00表示8个字节均可访问,0x01表示8个字节中只有第一个字节可以访问,0xFD表示这块内存已经被释放。

ASAN基本概念.jpg

  1. tag值存在内存中,但是这块区域称为shadow memory,它和用户可访问的内存之间存在固定的映射关系。
  2. 如果内存的tag值为0x00,则访问合规。如果是0x01,则需判断此次访问是否只访问第一个字节,如果不是则不合规(属于越界行为)。如果是0x00~0x07以外的其他值,则不合规。

让我们再来思考一下,所谓的是否合规到底在判断什么?其实它真正想判断的是内存的所有权问题。一块内存到底属于谁?我们以最容易发生内存问题的堆为例,当我们调用malloc时,系统会返回一个地址,而后续所有的内存操作都基于该地址。那么这时,虚拟意义上的“属于谁”就变成了实际意义上的“属于哪个指针”。指针和所指向的内存之间如何判断所有权?最直接的想法有点类似于“虎符”,指针和内存各持有一个tag,根据二者是否一致来判断所有权。在32位进程中,指针值的每一个比特都被用于寻址,因此没有多余的比特来记录所有权相关的信息(tag),当然也就无法通过对比来判断所有权。而在64位进程中,地址只有低48位用于寻址,因此高比特可以用来存储tag。HWASan和MTE都采用了这种方式,这也限定了它们只能用于64位进程,不过由于tag的可选范围有限,因此检测具有一定的漏检率(false-negatives)。32位进程中没办法判断所有权,只能退而求其次,给每块内存标记状态,只要访问特定状态的内存就不会出错,这也是ASan所采用的策略。

关于ASan和HWASan的实现细节,在此不再赘述。仅贴几张新画的图,有需要的可以参考我之前的文章。

[ASan的分配和释放]

ASAN图例.png

[HWASan的分配和释放]

HWASAN图例.png

[HWASan访问越界的情况]

HWASAN错误.png

不过当初这篇文章有些浅表,通篇都在讲述”是什么“,而少了”为什么“的思考。因此今年在研究MTE的时候,仔细思考了工具的底层逻辑,总结出了上述的两个步骤和三个问题。按照这样一种思考模式,便可以将Google开发的三个工具纳于统一的框架之下,也更容易明白它们之间的差异和各自的优缺点。

概述

MTE是ARM新架构(≥ARMv8.5)上的一个特性。虽然它需要ARM架构层面的支持,但这项工作其实一直在由Google主导。2018年,Google专门写了《Memory Tagging and how it improves C/C++ memory safety》的文章,阐述了memory tagging在软件和硬件层面的不同实现和性能比较,并倡议架构厂商们都集成memory tagging的功能。之后Google和ARM通力合作,最终在v8.5的架构上实现了MTE的功能。不过芯片的生产一般都晚于架构设计,所以MTE最终面向开发者的时间可能还需要等上一会儿。

MTE的原理和HWASan类似,但是在检测时机和tag的生成存储上有了硬件层面的支持。首先是检测时机:HWASan通过重新编译的方式,在所有内存访问前插入检测代码,属于软件层面的检测;而MTE是在ldr/str的指令内部进行检测,换言之,是硬件层面的检测。其次是tag的生成:HWASan通过软件随机的方式来生成tag;而MTE是通过IRG指令(≥ARMv8.5的架构才有的指令)来生成tag。最后是tag的存储:HWASan将内存的tag存在shadow memory(在内存中,具有虚拟地址)中,shadow memory和normal memory之间的映射关系由工具提前设定;MTE将内存的tag存在物理内存的特定区域中(没有虚拟地址,因此无法被用户直接访问),二者之间的映射关系由硬件保证。需要注意的是,HWASan是每16bytes的内存共享一个tag,tag自身为8bits(指针中的tag存在5663位)。MTE也是每16bytes的内存共享一个tag,但是它的tag长度只有4bits(指针中的tag存在5659位),因此可选择的数值更少。

这样的改动带来两个革命性的优势:

  1. 性能大大提升,MTE首次具备了线上部署(in production)的可能。
  2. 检测不再需要代码插桩,因此也无需重新编译,大大方便了使用。

以下是三个工具的开销对比,可以直观感受到MTE的提升。

Overhead type MTE HWASan ASan
RAM 3%-5% 10%-35% ~2x
CPU 0%-5% ~2x ~2x
Code size 2%-4% 40%-50% 50%-2x

检测模式

MTE具有两种检测模式:同步(Synchronous)和异步(Asynchronous)。同步模式性能开销大,但检测及时,错误信息收集详尽;异步模式虽然检测不及时,但性能开销小,可以用于release版本(也可以抵御内存攻击)。

  • 同步模式:在内存访问(ldr/str)时,同步检测tag是否匹配,如果不匹配则触发异常。进程收到SIGSEGV信号,其中的 siginfo.si_code = SEGV_MTESERR (SERR中的S表示synchronous),siginfo.si_addr = 。
  • 异步模式:在内存访问时,异步检测tag是否匹配,如果不匹配则更新TFSR_EL1寄存器中的TF0 bit。当下一次上下文切换(调度)或线程返回用户空间时,系统会去检测TFSR_EL1寄存器,进而产生SIGSEGV信号。只不过此时的 siginfo.si_code = SEGV_MTEAERR (AERR中的A表示asynchronous),siginfo.si_addr = 0 ,表明系统并不知道是哪一条具体的指令导致的问题。

为什么同步和异步模式之间存在性能差异呢?这需要牵涉到流水线优化的知识。内存访问可以分为读和写,写操作在流水线中是可以有些激进的优化策略的。譬如将连续的写操作合为一次写操作,或者将写操作缓存起来,稍后再发生实际的写动作。对同步检测而言,它必须要读取内存的tag,相当于在写操作的同时增加了一个读操作。基于内存一致性的规则,这将使得写操作的某些优化策略无法使用,因此CPU的运行效率降低。(这一块知识我只是粗浅的理解,如果有了解的朋友希望不吝赐教)

多重含义

MTE最原始的含义是ARM架构里的一个新的特性,相当于ARM提供了这种检测的能力,但是怎么使用、检测什么内存是需要操作系统和编译器配合的。因此LLVM和Android里都需要增加一些代码,方能让MTE真正地被开发者使用。

首先介绍下LLVM里关于MTE的相关工作。

可能有人会好奇,上面不是说了MTE的检测时机已经放到ldr/str指令的内部了么,为什么还需要编译器的介入呢?原因是为了进行栈上内存的检测。由于栈对象的分配没有显示的系统调用,因此必须通过函数插桩的方式来为该内存生成tag。通过-fsanitize=memtag -march=armv8.5-a+memtag的编译选项即可打开栈内存的检测。

接着是Android中关于MTE的相关工作。Android中MTE的检测主要针对的是堆内存,因此相关的代码都集成在分配器Scudo中,在动态内存分配时为其生成tag。

所以当我们讨论MTE时,一定需要注意语境,知道对方说的到底是ARM MTE,还是LLVM MTE,抑或是Scudo MTE。

指令简介

上面多次提到ARMv8.5以后的架构中,ldr/str指令的内部实现中集成了tag检测。那么如何确认这一点呢?

首先下载一份ARMv9指令集的官方文档,然后查看ldr指令的描述,如下图所示。

ARMv9 LDR 1.jpg

这个operation其实就是ldr指令具体做的事情,接着查看Mem,一路追踪下去发现:在MTE enable的情况下,会最终执行CheckTag的动作。

ARMv9 LDR 2.jpg

此外,ARM新的架构中还提供了一些特殊指令,方便快速地对tag进行操作。譬如如下两条指令。

  • IRG Xd, Xn 将Xn 拷贝到 Xd,并且为Xd中的值随机生成一个tag,存在它的[56:59]位。
  • STG Xd, [Xn] 将内存 [Xn, Xn + 16)的tag更新为 Xd的tag值。

ARM总共新增了10余条指令用于支持MTE。作为使用者其实没必要了解每条指令的含义,只需要明白这些指令是为了更快速、更方便地操作tag即可。

Scudo中的MTE

1. 检测方式

(关于Scudo的相关知识可以参考我之前的文章)

Scudo中的内存分配有两种方式,一种是Primary allocate,用于分配小内存,使用频繁;另一个是Secondary allocate,用于分配大内存(>256K)。

所有堆内存的分配最终都会调用Allocator::allocate函数。当内存分配出来后,系统会调用如下代码。

1
2
3
4
5
6
7
8
9
10
11
c++复制代码//Primary Allocator
const uptr OddEvenMask =
computeOddEvenMaskForPointerMaybe(Options, BlockUptr, ClassId);
TaggedPtr = prepareTaggedChunk(Ptr, Size, OddEvenMask, BlockEnd);
storePrimaryAllocationStackMaybe(Options, Ptr);
...
//Secondary Allocator
} else {
storeTags(reinterpret_cast<uptr>(Block), reinterpret_cast<uptr>(Ptr));
storeSecondaryAllocationStackMaybe(Options, Ptr, Size);
}

1.1 Primary Allocator

Primary Allocator动态分配的内存块如下所示。Block是大小相同且连续排列的内存块,其中Header存储了该内存块的一些元数据便于释放时进行状态检测,而真实返回给用户的指针为Ptr。

Chunk Tag.png

对于分配出来的内存块,Primary Allocator使用如下代码给返回地址(指针)Ptr增加tag。

1
2
3
c++复制代码const uptr OddEvenMask =
computeOddEvenMaskForPointerMaybe(Options, BlockUptr, BlockSize);
TaggedPtr = prepareTaggedChunk(Ptr, Size, OddEvenMask, BlockEnd);

OddEvenMask先按下不表,这里我们只关心prepareTaggedChunk。其内部所做的事情正是为刚分配的内存生成tag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
c++复制代码inline void *prepareTaggedChunk(void *Ptr, uptr Size, uptr ExcludeMask,
uptr BlockEnd) {
// Prepare the granule before the chunk to store the chunk header by setting
// its tag to 0. Normally its tag will already be 0, but in the case where a
// chunk holding a low alignment allocation is reused for a higher alignment
// allocation, the chunk may already have a non-zero tag from the previous
// allocation.
__asm__ __volatile__(".arch_extension memtag; stg %0, [%0, #-16]"
:
: "r"(Ptr)
: "memory");

uptr TaggedBegin, TaggedEnd;
setRandomTag(Ptr, Size, ExcludeMask, &TaggedBegin, &TaggedEnd);

// Finally, set the tag of the granule past the end of the allocation to 0,
// to catch linear overflows even if a previous larger allocation used the
// same block and tag. Only do this if the granule past the end is in our
// block, because this would otherwise lead to a SEGV if the allocation
// covers the entire block and our block is at the end of a mapping. The tag
// of the next block's header granule will be set to 0, so it will serve the
// purpose of catching linear overflows in this case.
uptr UntaggedEnd = untagPointer(TaggedEnd);
if (UntaggedEnd != BlockEnd)
__asm__ __volatile__(".arch_extension memtag; stg %0, [%0]"
:
: "r"(UntaggedEnd)
: "memory");
return reinterpret_cast<void *>(TaggedBegin);
}

由于需要直接使用汇编指令stg,因此prepareTaggedChunk中内嵌了一些汇编代码(setRandomTag函数中也有一些汇编代码)。该函数有四个参数,含义分别如下:

  • Ptr:一个没有tag的指针,也即它的56~59位均为0。表明chunk的起始地址。
  • Size:要求分配的大小。
  • ExcludeMask:MTE的tag为4 bits,因此有0~15共16种可能。Android默认不选用0,因此还剩下15种可能。ExcludeMask用于从15种可能中再删去一些选择,譬如该值为0x6,则tag不会选择1或2(0x6==0b0110,从低到高的1、2比特均为1)。
  • BlockEnd:块的结束地址,由于Scudo中region存储的都是大小相同的块,因此块大小可能大于要求分配的大小。

prepareTaggedChunk中的setRandomTag会以16bytes为单位,循环为chunk中的所有内存分配tag。最终的tag情况如下所示:

Chunk with Tag.png

Tag生成之后,越界的内存访问就会因tag不匹配而发生SIGSEGV。不过需要注意一点,Unused内存中只对第一个16bytes生成了tag,这样线性的越界将会100%检测出来,而非线性的跨越式越界则是概率性检测出来。至于为什么没有将Unused内存全部tag为0,Google的工程师说是基于性能的考虑,不过这样确实可能会漏检一些跨越式的越界。据统计,Chromium的开发实践中约13%的overflow是跨越式的overflow。

上文提到了越界的检测方法,那么UAF(Use-After-Free)是如何检测的呢?

当一块内存释放时,系统会去调用Scudo中的quarantineOrDeallocateChunk方法。释放的内存会生成一个新的tag,该tag有别于之前的tag,因此可以保证immediate UAF被100%地检测出来。不过长时间的UAF可能会因为该内存经历了多次分配/释放而发生漏检。

接着再介绍下OddEvenMask,这是一个很有趣的知识点。

上文提到,这个mask会限制tag从哪些数中随机选取。对于虚拟地址连续的内存块(Block),OddEvenMask将会间隔地赋值为0xaaaa和0x5555。0xa=0b1010,0x5=0b0101,可以发现这两个mask是完全互斥的tag集合。OddEvenMask为0xaaaa,则tag只能选择奇数,反之tag只能选择非0的偶数。

1
2
3
4
5
6
7
8
9
10
11
12
c++复制代码uptr computeOddEvenMaskForPointerMaybe(Options Options, uptr Ptr,
uptr ClassId) {
if (!Options.get(OptionBit::UseOddEvenTags))
return 0;

// If a chunk's tag is odd, we want the tags of the surrounding blocks to be
// even, and vice versa. Blocks are laid out Size bytes apart, and adding
// Size to Ptr will flip the least significant set bit of Size in Ptr, so
// that bit will have the pattern 010101... for consecutive blocks, which we
// can use to determine which tag mask to use.
return 0x5555U << ((Ptr >> SizeClassMap::getSizeLSBByClassId(ClassId)) & 1);
}

这样一来,相邻的两个内存块一定不会使用相同的tag,保证了相邻的越界可以100%被检测出来。不过凡事有利有弊,由于每个内存块tag可选择的范围缩小一半,因此UAF的漏检率(false-negatives)反倒提高了。该特性可以通过mallopt系统调用的M_MEMTAG_TUNING选项进行选择。

1
2
3
4
csharp复制代码int mallopt(M_MEMTAG_TUNING, level)
where level is:
● M_MEMTAG_TUNING_BUFFER_OVERFLOW (OddEvenMask打开,默认值)
● M_MEMTAG_TUNING_UAF (OddEvenMask关闭)

1.2 Secondary Allocator

Secondary MTE.png

Secondary Allocator通过mmap分配出新的vma区域。上图中的Content是用户真实数据存放的位置,它的结束地址是按页对齐的。起始地址Ptr前面存放两个Header,一个是Chunk Header,与Primary Allocator保持一致;另一个是LargeBlock Header,属于Secondary独有的设计,其中主要存储前后vma的指针(链表结构)。再往前是补齐的内存,一直补齐到页边界。此外,前后再各加一个不可访问的保护页。

当MTE开启后,分配器不会为Content设置tag,因此它的tag保持默认值0。Chunk Header对应的tag设置为固定值2,LargeBlock Header和Padding对应的tag设置为固定值1。这样一来,前后溢出均可被检测:

  • 线性Overflow会直接访问置于尾部的Guard Page,由于其不可访问,因此会直接触发SIGSEGV。
  • 线性Underflow如果访问到Chunk Header/LargeBlock Header/Padding,由于其tag不为0(而指针tag为0),因此会产生SIGSEGV的错误;如果访问到头部的Guard Page,则也会触发SIGSEGV。

1.3 Short granules

上面的讨论有个前提,即动态分配的内存大小是16字节的整数倍。可是如果是下面的代码呢?

1
2
c复制代码char *p = (char *)malloc(88);
*(p + 89) = 'n';

HWASan为了能够进行更加细粒度的溢出检测,增加了short granules的特性,详情可以参考文章。因此上面的溢出情况可以被HWASan检测出来。

但是Scudo MTE却无法检测出该错误,原因是它并不支持short granules。由于tag在MTE中仅由4bits构成,所以支持short granules会极大的压缩tag选取范围,也会大大提升漏检率。两害相权取其轻,Google团队最终放弃了在MTE中对short granules的支持。不过好在Scudo分配出来的Block都是按16字节对齐的,所以即便发生了这种溢出,也不会踩踏有效数据。

1.4 小结

Scudo中的MTE目前只检测native堆的内存,检测的错误类型主要为OOB(Out-of-Bounds,包含Underflow和Overflow)和UAF(Use-After-Free)。另外,Scudo本身也支持Double-Free的检测。

2. 调用栈的保存和恢复

(不感兴趣的可以跳过,细节较多)

当MTE设置为同步模式时,Scudo会在分配和释放内存块的时候去记录当时的调用栈信息。它们本质上是由返回地址构成的数组。在ARM64上,x29寄存器用于保存帧指针FP,x30寄存器又称为LR寄存器,用于保存返回地址。在调用过程中,每个函数开始时都会将寄存器压栈,结束时出栈。因此栈中就保存了一系列帧指针和返回地址,这两个值通常是连续存放的,而不同帧的FP又呈现出链表结构,因此遍历链表便可以将这些值全部取出。这种方式称为基于FP的栈帧回溯方法,比传统回溯方法要快。不过这种方法可行的前提是函数调用时对FP进行压栈,该行为可由编译选项-fomit-frame-pointer和-fno-omit-frame-pointer进行控制。在64位的Android上,默认会对FP进行压栈,因此该回溯方法有效。如果碰到没有开启FP压栈的三方库,该方法虽然会失效,但不会死循环或崩溃,只是缺少些调试信息。

不同帧的返回地址构成了一个数组,它反映的就是当时的调用栈信息。为了控制这些信息所占用的内存,返回地址组成的数组长度不得超过64,也即最多存储64帧调用栈信息。对于调试而言,64帧的调用栈已经足够看出问题。

由于每个调用栈的大小不一致,所以没法创建统一的数组长度。如果将数组长度设为64,那么当调用栈不足64帧时会浪费内存空间。所以为了更高效地使用内存,Scudo中用一个大型数组存储下所有的返回地址。该数组长度为524288(1<<19),不同调用栈的返回地址间会插入一个元素进行分隔。这个用于分隔的元素称为”stack trace marker”。那么如何区分一个marker和一个正常的返回地址呢?让我们把目光投向marker的最后一位。由于PC值在64位的机器上都是按4字节对齐的,所以其最后一位必然为0。这样我们就可以人为地将marker的最后一位设为1,以区分它和返回地址。marker的具体含义如下所示。

Stack trace数组.png

通过上图可以看到,marker中的1~32bits用来存储hash值。这个值由调用栈的所有返回地址经由散列算法共同计算得出,相当于调用栈的特殊ID。

Primary在分配时将hash值存在自己的Block中。如下图所示,Header后面原本用于对齐的padding目前用来存储hash值。Padding总长度为8字节,其中4字节存储hash值,另外4字节存储分配时的线程ID。

stack trace存储.png

这里的hash值相当于调用栈的特殊ID,那么如何通过它来定位返回地址在数组中的序号呢?答案是需要通过一层tab数组进行中转。因为hash值本身具有随机性,所以无法直接将它和具有规律性排列的返回地址关联起来。

首先用hash值模上65536,得到一个tab数组的序号。接着取出tab数组中的元素,元素的值即为返回地址数组的序号。通常这个序号所对应的元素是”stack trace marker”,根据marker中记录的调用栈长度便可以依次取出后续所有的返回地址。

当初看到这个设计时我就问自己,为什么不可以直接将marker的序号存在Block中,而一定要存hash值呢?

后来才想明白原因。返回地址数组被设计成Ring Buffer,因此其中的内容可能被循环覆盖。如果将marker的序号存在Block中,则它可能取到完全不属于自己的调用栈。而采用hash值就可以规避这个问题。拿到marker后去比对下Block中的hash值和marker中的hash值是否一致,不一致则表明自己原来的调用栈已经被覆盖了。

tab数组的长度为65536,返回地址数组的长度为524288。二者相除的结果为8,表明如果平均调用栈长度小于7,则scudo最多可以记录65536个调用栈;如果长度大于7,则scudo最多可记录的调用栈数小于65536。当调用栈被覆盖后,虽然问题依然可以报出来,但缺少关键的调试信息后,内存问题还是很难定位。

当Primary Block释放时,这块内存便可以分配给其他人使用,因此之前存在Block中的hash值和此次释放的调用栈的hash值都要另存他处。

为此scudo中实现了一个全局的Entry数组,长度为32768。Entry结构体中的AllocationTrace存储的是分配时调用栈的hash值,DeallocationTrace存储的是释放时调用栈的hash值。当Primary Block释放时,它首先会取出存在Block中的分配调用栈的hash值,将它存到Entry的AllocationTrace字段中,之后将是释放的调用栈hash值存到Entry的DeallocationTrace中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
c++复制代码  struct AllocationRingBuffer {
struct Entry {
atomic_uptr Ptr;
atomic_uptr AllocationSize;
atomic_u32 AllocationTrace;
atomic_u32 AllocationTid;
atomic_u32 DeallocationTrace;
atomic_u32 DeallocationTid;
};

atomic_uptr Pos;
#ifdef SCUDO_FUZZ
static const uptr NumEntries = 2;
#else
static const uptr NumEntries = 32768;
#endif
Entry Entries[NumEntries];
};
AllocationRingBuffer RingBuffer;

此外,Entry数组不单用于存储Primary Block释放后的hash值,还用于存储Secondary Block的hash值,不论其是否释放。当初看到这里的时候,我脑中又出现了一个问题:为什么Secondary和Primary在处理未释放Block的hash值时做法不一致?

原因是Primary Allocator和Secondary Allocator对于其中Block的管理方式不同。Primary中的Block是线性排列,而Secondary里的Block是链表结构。虽然我们可以通过遍历的方式寻找到Secondary里的目标Block,但是需要在遍历过程中增加很多对目标进程内存的拷贝操作。而如果将Secondary的调用栈信息全部存在Entries数组中,我们只需在收集调用栈信息前将这个数组拷贝一次即可。

讨论完了调用栈的保存,那么调用栈的恢复是怎么进行的呢?

  1. 根据PC值在memory maps中找到对应的elf文件。
  2. 根据elf文件中的symbols信息,查询该PC值属于哪个函数的执行范围。
  3. Demangle这个函数的名称,得到更具可读性的字符串。

根据上述的步骤,可以知道有两种情况会丢失调试信息。

  1. 当相应的动态库在内存错误发生前被卸载,那么那一帧最终就会显示<unknown>。
  2. 当相应的动态库通过-fvisibility=hidden的编译选项来关闭符号表的输出时(商业APK),我们将无法判断PC值属于哪个函数,因此那一帧只会打印so的名称,但没有函数名。

3. 判断内存错误的原因

当MTE设置为同步模式时,scudo不仅会输出相应的调用栈,还会输出内存错误可能的原因,譬如是溢出问题还是UAF的问题。不过这种判断只是一种参考(有概率发生误判),而并非金标准。

用户空间采用debuggerd_signal_handler作为SIGSEGV的处理函数。处理时会fork出一个crash_dump进程,用于收集错误发生时的调用栈,与此同时它也会通过__scudo_get_error_info收集更多的错误信息。

在getErrorInfo函数中,会同时收集调用栈和错误原因。首先会根据错误地址的tag是否为0来决定要不要做getInlineErrorInfo。这是因为Primary分配的指针tag非0,而Secondary分配的指针tag为0。getInlineErrorInfo是从Block中获取hash值的,而这种方式只对Primary有效。

概括地说,getInlineErrorInfo用于输出Primary OOB问题,收集当初分配时的调用栈。getRingBufferErrorInfo用于输出Primary UAF、Secondary OOB和Secondary UAF问题,其中UAF问题既收集当初分配时的调用栈,也收集上一次释放的调用栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
c++复制代码static void getErrorInfo(struct scudo_error_info *ErrorInfo,
uintptr_t FaultAddr, const char *DepotPtr,
const char *RegionInfoPtr, const char *RingBufferPtr,
const char *Memory, const char *MemoryTags,
uintptr_t MemoryAddr, size_t MemorySize) {
*ErrorInfo = {};
if (!allocatorSupportsMemoryTagging<Params>() ||
MemoryAddr + MemorySize < MemoryAddr)
return;

auto *Depot = reinterpret_cast<const StackDepot *>(DepotPtr);
size_t NextErrorReport = 0;

// Check for OOB in the current block and the two surrounding blocks. Beyond
// that, UAF is more likely.
if (extractTag(FaultAddr) != 0)
getInlineErrorInfo(ErrorInfo, NextErrorReport, FaultAddr, Depot,
RegionInfoPtr, Memory, MemoryTags, MemoryAddr,
MemorySize, 0, 2);

// Check the ring buffer. For primary allocations this will only find UAF;
// for secondary allocations we can find either UAF or OOB.
getRingBufferErrorInfo(ErrorInfo, NextErrorReport, FaultAddr, Depot,
RingBufferPtr);

// Check for OOB in the 28 blocks surrounding the 3 we checked earlier.
// Beyond that we are likely to hit false positives.
if (extractTag(FaultAddr) != 0)
getInlineErrorInfo(ErrorInfo, NextErrorReport, FaultAddr, Depot,
RegionInfoPtr, Memory, MemoryTags, MemoryAddr,
MemorySize, 2, 16);
}

收集的错误信息通过ErrorInfo(类型为scudo_error_info)存储,它里面包含一个定长为3的数组,表明可以为一个内存错误判定最多三种可能的原因。这种判断只是一种参考,而并非金标准。譬如一个UAF的问题,当我们检查它两侧的Block时,可能会发现和错误地址tag一样的Block,这样该问题也可以被判定为OOB的问题。至于具体是什么问题,还需使用者结合调用栈自己去判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
c++复制代码struct scudo_error_info {
struct scudo_error_report reports[3];
};

struct scudo_error_report {
enum scudo_error_type error_type;

uintptr_t allocation_address;
uintptr_t allocation_size;

uint32_t allocation_tid;
uintptr_t allocation_trace[64];

uint32_t deallocation_tid;
uintptr_t deallocation_trace[64];
};

使用方法

Android提供了多种方式来开启MTE。乍一看很容易迷糊,但如果了解每种方式运行的原理,用起来便会得心应手的多。

从大的方向上可以分为以下几种类型:

  1. 运行时环境变量
  2. 系统Property
  3. 编译时环境变量
  4. 编译选项
  5. 应用Manifest配置
  6. 运行时API

接下来依次介绍。

1. 运行时环境变量

MEMTAG_OPTIONS=(off|sync|async)

该环境变量的检测过程发生在可执行文件重定位之前,是由linker发起的。因此一旦该环境变量设为sync或async,那么之后创建的任何native进程都将开启MTE检测。

不过Android应用进程(Java进程)并不会受到这个环境变量的影响:因为应用进程由zygote fork而来,而非通过exec可执行文件的方式打开。

通常,我们在/system/core/rootdir/init.environ.rc.in中去增加新的环境变量(这样便需要重新编译),譬如下面的方式可以打开MTE的同步模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bash复制代码# set up the global environment
on early-init
+ export MEMTAG_OPTIONS sync (打开MTE的同步检测模式)
export ANDROID_BOOTLOGO 1
export ANDROID_ROOT /system
export ANDROID_ASSETS /system/app
export ANDROID_DATA /data
export ANDROID_STORAGE /storage
export ANDROID_ART_ROOT /apex/com.android.art
export ANDROID_I18N_ROOT /apex/com.android.i18n
export ANDROID_TZDATA_ROOT /apex/com.android.tzdata
export EXTERNAL_STORAGE /sdcard
export ASEC_MOUNTPOINT /mnt/asec
%EXPORT_GLOBAL_ASAN_OPTIONS%
%EXPORT_GLOBAL_GCOV_OPTIONS%
%EXPORT_GLOBAL_CLANG_COVERAGE_OPTIONS%
%EXPORT_GLOBAL_HWASAN_OPTIONS%

2. 系统Property

arm64.memtag.process. = (off|sync|async)

这里的basename通常指的是可执行文件的名称,所以也只会影响native进程。不过有个例外是system_server,因为forkSystemServer中会主动读取”arm64.memtag.process.system_server”系统属性的值。

举个例子,下面的操作会同时打开/system/bin/ping和/data/local/tmp/ping的MTE选项,之后启动的ping进程都会开启MTE的同步模式。

1
shell复制代码$ setprop arm64.memtag.process.ping sync

3. 编译时环境变量

SANITIZE_TARGET & SANITIZE_TARGET_DIAG

异步模式:

1
2
shell复制代码hangl@ubuntu$ export SANITIZE_TARGET=memtag_heap
hangl@ubuntu$ m

同步模式:

1
2
shell复制代码hangl@ubuntu$ export SANITIZE_TARGET=memtag_heap SANITIZE_TARGET_DIAG=memtag_heap
hangl@ubuntu$ m

首先通过export声明环境变量,之后通过Android编译系统提供的快捷操作m来编译整个system image。值得注意的是,同步模式需要同时声明两个环境变量,后一个变量的DIAG意味着diagnostics mode(诊断模式),这表明不单单要检测内存错误,还要收集尽可能多的调试信息。

4. 编译选项

memtag_heap

异步模式:

1
2
3
4
makefile复制代码//Android.bp
sanitize: {
memtag_heap: true,
}

同步模式:

1
2
3
4
5
6
7
makefile复制代码//Android.bp
sanitize: {
memtag_heap: true,
diag: {
memtag_heap: true,
},
}

不论是编译时环境变量还是编译选项,它们的本质都是往ELF文件中增添一个新的note section(注释节)。这个section里的内容记录了MTE的配置信息,会由linker在程序启动时读取。下面代码展示的就是如何将异步模式的MTE配置信息存入note section。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
assembly复制代码__bionic_asm_custom_note_gnu_section()
.section ".note.android.memtag", "a", %note
.p2align 2
.long 1f - 0f // int32_t namesz
.long 3f - 2f // int32_t descsz
.long NT_TYPE_MEMTAG // int32_t type
0:
.asciz "Android" // char name[]
1:
.p2align 2
2:
.long (NT_MEMTAG_LEVEL_ASYNC | NT_MEMTAG_HEAP) // value
3:
.p2align 2

存入ELF文件的note信息可以通过llvm-readelf读取出来,以下为示例。

1
2
3
4
5
6
shell复制代码hangl@ubuntu$ llvm-readelf --notes app_process64
...
Displaying notes found in: .note.android.memtag
Owner Data size Description
Android 0x00000004 Unknown note type: (0x00000004)
description data: 05 00 00 00

名称为”.note.android.memtag”的section是我们关注的。Owner为”Android”,是固定的字符串;Date Size为4,表示description data的存储空间为4字节;Description下面的数据实质上是Type,0x4即为NT_TYPE_MEMTAG;description data才是MTE的配置信息,0x5表示(NT_MEMTAG_LEVEL_ASYNC | NT_MEMTAG_HEAP),0x6表示(NT_MEMTAG_LEVEL_SYNC | NT_MEMTAG_HEAP),因为NT_MEMTAG_LEVEL_ASYNC =1,NT_MEMTAG_LEVEL_SYNC =2,NT_MEMTAG_HEAP=4。

5. 应用Manifest配置

android:memtagMode=(off|default|sync|async)

如果在标签下配置,则该属性对应用中的所有进程都生效;如果在标签下配置,则该属性仅对单个进程生效,且会覆盖标签下的配置。

不过需要注意,该配置生效有一个前提,即zygote进程的MTE已经打开,否则所有经由zygote fork出来的进程都无法开启MTE。

1
2
3
ini复制代码<application
...
android:memtagMode="async">

在开发者模式中,我们可以直接在设置选项中修改单个应用的MTE配置。

Settings > System > Developer options > App Compatibility Changes,其中NATIVE_MEMTAG_ASYNC和NATIVE_MEMTAG_SYNC的选项与MTE相关。

此外,am指令也支持修改MTE配置。

1
shell复制代码$ adb shell am compat enable NATIVE_MEMTAG_[A]SYNC my.app.name

6. 运行时API

int mallopt(M_BIONIC_SET_HEAP_TAGGING_LEVEL, level)

以上5种方法必须在进程启动前进行配置,可是如果一个进程已经在运行中,我们还有办法改变它的MTE配置么?

答案是使用mallopt函数,上述函数中的level可以从以下三个选项中选择。

  • M_HEAP_TAGGING_LEVEL_NONE
  • M_HEAP_TAGGING_LEVEL_ASYNC
  • M_HEAP_TAGGING_LEVEL_SYNC

不过这个API的使用有些限制,原因是进程的MTE模式只能从开启状态切换到关闭状态,而无法逆向操作,因为中途开启MTE会导致先前分配的内存(没有生成tag)无法进行检测。具体的限制如下:

  1. 进程必须在启动时就已经开启MTE检测。
  2. M_HEAP_TAGGING_LEVEL_SYNC或M_HEAP_TAGGING_LEVEL_ASYNC切换到M_HEAP_TAGGING_LEVEL_NONE是一个单向的操作,一旦关闭,无法再次开启。

这个API对于线上应用其实有着挺大的帮助。平时我们可以对APP开启异步模式的检测(性能优先),一旦检测到问题,便可以在下次启动时切换到同步模式(调试优先)。

不同方式的优先级

  • 最高优先级:运行时环境变量(全局配置,对所有native进程生效),一旦该变量设定以后,下面的配置都不会起作用。
  • 中等优先级:系统property,该变量可以为特定进程配置MTE。
  • 最低优先级:编译时环境变量及编译选项,它们会在ELF文件中增加note section,但note section的检测只会发生在上面两个方式没有使用的时候。

需要注意的是,上述方式只作用于native进程,也即通过exec加载ELF文件来启动的进程(有一个例外是system_server,通过”arm64.memtag.process.system_server”系统属性控制)。

APP由zygote fork出来,因此不会走ELF文件加载的流程。子进程fork出来后,会执行SpecializeCommon函数,其中会调用mallopt对MTE进行配置。

1
c++复制代码mallopt(M_BIONIC_SET_HEAP_TAGGING_LEVEL, heap_tagging_level);

默认情况下,heap_tagging_level的值为M_HEAP_TAGGING_LEVEL_NONE。当Manifest或Compatibility Changes中开启了MTE后,heap_tagging_level的值便会发生更改。

案例解析

当MTE检测到问题后,会生成相应进程的tombstone文件,下面给出一个示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
less复制代码*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: xxxx
Revision: '0'
ABI: 'arm64'
Timestamp: 1970-01-01 00:36:59.185340333+0000
pid: 3304, tid: 3304, name: main >>> zygote64 <<<
uid: 0
tagged_addr_ctrl: 000000000007fff3
signal 11 (SIGSEGV), code 9 (SEGV_MTESERR), fault addr 0x800007c6c70f600
x0 0000000000000025 x1 0800007c6c70f5e9 x2 0000000000084000 x3 0000000000000000
...

backtrace:
#00 pc 000000000009cc38 /apex/com.android.runtime/lib64/bionic/libc.so (__openat+8)
#01 pc 000000000005b234 /apex/com.android.runtime/lib64/bionic/libc.so (__open_2+76)
...
Note: multiple potential causes for this crash were detected, listing them in decreasing order of probability.

Cause: [MTE]: Buffer Underflow, 128 bytes left of a 96-byte allocation at 0x7c6c70f680 // 0x680 -0x600 = 128(0x80) bytes.

allocated by thread 3304:
#00 pc 0000000000043f34 /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::allocate(unsigned long, scudo::Chunk::Origin, unsigned long, bool)+1260)
#01 pc 0000000000044204 /apex/com.android.runtime/lib64/bionic/libc.so (scudo_malloc+36)
#02 pc 000000000003ec7c /apex/com.android.runtime/lib64/bionic/libc.so (malloc+36)
...

Cause: [MTE]: Buffer Overflow, 0 bytes right of a 96-byte allocation at 0x7c6c70f5a0 // 0x600 - 0x5a0 = 0x60(96) bytes

allocated by thread 3304:
#00 pc 0000000000043f34 /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::allocate(unsigned long, scudo::Chunk::Origin, unsigned long, bool)+1260)
#01 pc 0000000000044204 /apex/com.android.runtime/lib64/bionic/libc.so (scudo_malloc+36)
#02 pc 000000000003ec7c /apex/com.android.runtime/lib64/bionic/libc.so (malloc+36)
...

Cause: [MTE]: Buffer Underflow, 1696 bytes left of a 88-byte allocation at 0x7c6c70fca0 //0xca0 - 0x600 = 1696 bytes

allocated by thread 3304:
#00 pc 0000000000043f34 /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::allocate(unsigned long, scudo::Chunk::Origin, unsigned long, bool)+1260)
#01 pc 00000000000445ac /apex/com.android.runtime/lib64/bionic/libc.so (scudo::Allocator<scudo::AndroidConfig, &(scudo_malloc_postinit)>::reallocate(void*, unsigned long, unsigned long)+248)
#02 pc 0000000000044450 /apex/com.android.runtime/lib64/bionic/libc.so (scudo_realloc+36)
#03 pc 000000000003ef48 /apex/com.android.runtime/lib64/bionic/libc.so (realloc+84)
...

上面是MTE同步模式下输出的tombstone信息,tagged_addr_ctrl中记录了三个信息:

tag_ctrl.jpg

  • 最低位为1表示MTE检测功能打开。
  • 1~2bits01意味着检测模式为同步,10意味着检测模式为异步。
  • 3~18bits记录了tag选取的范围,1111111111111110意味着tag不选择0。

因此0x7fff3表示同步模式打开,且tag不选择0。

Code SEGV_MTESERR表示synchronous,SEGV_MTEAERR表示asynchronous。

Backtrace标签下记录了错误发生时当前线程的调用栈,之后的三个Cause分别记录了错误的三种可能。OOB问题的检测由近及远,且先Underflow,后Overflow。

为了方便比对,下面省略调用栈,直接将三个Cause罗列在一起。

1
2
3
ini复制代码Cause: [MTE]: Buffer Underflow, 128 bytes left of a 96-byte allocation at 0x7c6c70f680  // 0x680 -0x600 = 128(0x80) bytes.
Cause: [MTE]: Buffer Overflow, 0 bytes right of a 96-byte allocation at 0x7c6c70f5a0 // 0x600 - 0x5a0 = 0x60(96) bytes
Cause: [MTE]: Buffer Underflow, 1696 bytes left of a 88-byte allocation at 0x7c6c70fca0 //0xca0 - 0x600 = 1696 bytes

案例.png

错误地址指向的Block属于Class 6的region,其中每个Block的大小都为112字节(8字节Header+8字节Padding+96字节存储空间)。不过96字节的存储空间并不意味着完全使用,譬如可以只使用88字节,剩下8字节unused。

检测时先去寻找错误Block右边的Block,发现右边第一个Block的tag和错误地址一样,因此判定为Buffer Underflow。”128 bytes left of a 96-byte allocation at 0x7c6c70f680”,表示错误地址和右边Block的头部地址之间相差128bytes,而右边Block的真实content大小为96bytes。

接着查看左边第一个Block,发现tag也和错误地址一样,因此判定为Buffer Overflow。”0 bytes right of a 96-byte allocation at 0x7c6c70f5a0”表示错误地址刚好等于左边Block的尾部地址,且左边Block的真实content大小也为96bytes。

左右各判定最多15个Block,判断到右边第15个Block时,发现它的tag和错误地址的tag也是一样的,因此第三种可能的原因判断为Underflow。”1696 bytes left of a 88-byte allocation at 0x7c6c70fca0”,表示错误地址距离右边第15个Block的头部有1696bytes的距离,且该Block的真实content大小为88bytes。(1696+88)/112=16,由于本身Block占据一个位置,因此刚好是右边第15个Block。

接着结合每种类型的调用栈,判定Overflow应该是这次错误的真实原因,因为它的调用栈和错误调用栈都和Gralloc2Mapper的preload有关。

后记

随着Android S(12)的正式发布,估计会有越来越多的人听到MTE。所以我就想着写一篇尽可能详细的文章,帮助大家更深入地了解它、使用它。另外在研究源码时,我也发现了scudo MTE中的一些小瑕疵,给Google提了3个bug和2个建议,幸运的是都被采纳了,也算为开源社区做些贡献。

不知不觉,本文已过万字,但其中肯定还有不少细节没有推敲到位,希望各位朋友发现之后帮忙指正,多谢多谢!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

这个开源组织里的项目都是精品

发表于 2021-09-30

前言

在开源中国里,不知道大家有没有留意到一个Java开源组织——Dromara?

这个组织是由Apache ShenYu(前身是Soul网关)的作者创立,多位Java开源作者参与的一个Java开源组织。

在开源中国社区,很多Java开源作者都是各自为战,独立运营项目。Domara组织的诞生就是为了联合Java开源的力量,共建社区,资源共享,共同推行中国Java开源事业的发展。

image.png

目前Dromara社区拥有9个GVP项目,和一些Star数量很高的项目。这些开源项目社区都很活跃,每一个都是能提高工作效率的精品开源作品。下面就来盘点下Dromara组织其中的4个开源项目。都是非常实用的工具,用好这些将会让你的生产效率大大提升!

Sa-Token

首先我要介绍的是Sa-Token,可能是史上功能最全的轻量级 Java 权限认证框架。

简单的使用方式,丰富的特性,强大的功能,你有什么理由拒绝?

官方网站:sa-token.dev33.cn/

Gitee托管仓库:gitee.com/dromara/sa-…

Github托管仓库:github.com/dromara/Sa-…

image.png

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、Session会话、单点登录、OAuth2.0、微服务网关鉴权
等一系列权限相关问题。

Sa-Token 的 API 设计非常简单,有多简单呢?以登录认证为例,你只需要:

1
2
3
4
5
6
java复制代码// 在登录时写入当前会话的账号id
StpUtil.login(10001);

// 然后在需要校验登录处调用以下方法:
// 如果当前会话未登录,这句代码会抛出 `NotLoginException` 异常
StpUtil.checkLogin();

至此,我们已经借助 Sa-Token 完成登录认证!

此时的你小脑袋可能飘满了问号,就这么简单?自定义 Realm 呢?全局过滤器呢?我不用写各种配置文件吗?

没错,在 Sa-Token 中,登录认证就是如此简单,不需要任何的复杂前置工作,只需这一行简单的API调用,就可以完成会话登录认证!

当你受够 Shiro、SpringSecurity 等框架的三拜九叩之后,你就会明白,相对于这些传统老牌框架,Sa-Token 的 API 设计是多么的简单、优雅!

权限认证示例(只有具备 user:add 权限的会话才可以进入请求)

1
2
3
4
5
6
java复制代码@SaCheckPermission("user:add")
@RequestMapping("/user/insert")
public String insert(SysUser user) {
// ...
return "用户增加";
}

将某个账号踢下线(待到对方再次访问系统时会抛出NotLoginException异常)

1
2
java复制代码// 使账号id为 10001 的会话强制注销登录
StpUtil.logoutByLoginId(10001);

在 Sa-Token 中,绝大多数功能都可以 一行代码 完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码StpUtil.login(10001);                     // 标记当前会话登录的账号id
StpUtil.getLoginId(); // 获取当前会话登录的账号id
StpUtil.isLogin(); // 获取当前会话是否已经登录, 返回true或false
StpUtil.logout(); // 当前会话注销登录
StpUtil.logoutByLoginId(10001); // 让账号为10001的会话注销登录(踢人下线)
StpUtil.hasRole("super-admin"); // 查询当前账号是否含有指定角色标识, 返回true或false
StpUtil.hasPermission("user:add"); // 查询当前账号是否含有指定权限, 返回true或false
StpUtil.getSession(); // 获取当前账号id的Session
StpUtil.getSessionByLoginId(10001); // 获取账号id为10001的Session
StpUtil.getTokenValueByLoginId(10001); // 获取账号id为10001的token令牌值
StpUtil.login(10001, "PC"); // 指定设备标识登录,常用于“同端互斥登录”
StpUtil.logoutByLoginId(10001, "PC"); // 指定设备标识进行强制注销 (不同端不受影响)
StpUtil.openSafe(120); // 在当前会话开启二级认证,有效期为120秒
StpUtil.checkSafe(); // 校验当前会话是否处于二级认证有效期内,校验失败会抛出异常
StpUtil.switchTo(10044); // 将当前会话身份临时切换为其它账号

即使不运行测试,相信您也能意会到绝大多数 API 的用法。

想要了解更多请参考:gitee.com/dromara/sa-…

Forest

一款极大程度解放你的Http接入工作的强大Http客户端框架。

Http协议很复杂吗?那是因为你还没有使用过Forest,虽然业内有很多其他优秀的Http客户端,但是你错过了Forest,将会错过一大片优雅美丽的森林。

官方网站:forest.dtflyx.com

Gitee托管仓库:gitee.com/dromara/for…

Github托管仓库:github.com/dromara/for…

image.png

Forest 是一个开源的 Java HTTP 客户端框架,用来访问第三方服务 RESTful 接口。

它能够将 HTTP 的请求参数绑定到 Java 接口上,之后调用 Java 接口就等于在发送 HTTP 请求。一切面向于接口。

很多公司需要在 Java 后台调用许多第三方 HTTP 接口,比如微信支付、友盟等等第三方平台。

公司内部还有很多服务是用世界最好语言写的,接口自然也只能通过 HTTP 接口来调用。于是日积月累下来,在 Java 代码中就有许许多多各式各样的 HTTP 调用接口,而且调用方式也不统一,有 HttpClient 写的、有 OkHttp 写的、有自己包装的,光公司内部不同人包装的 HTTP 工具类就有两三种。

而且 url 基本写死在代码中,很难维护,不同接口又有不同的参数传输方式,有 GET 、有 POST,有 JSON 传输的、有 XML 传输的。 当有一个接口需要修改,完了,光找到代码在什么地方就要花半天时间。

而 Forest 能帮助我很好地将 HTTP 代码和业务代码解耦开来,请求调用者不必关心 HTTP 相关的细节。

自动拼接 HTTP 的各种参数

包括 URL、Header、Body 等等参数都能依靠 Java 注解来声明。

这里就举一个高德地图的栗子,来看看 Forest 是如何优雅的声明 HTTP 请求接口的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码/**
* 高德地图服务客户端接口
*/
@BaseRequest(baseURL = "http://ditu.amap.com")
public interface Amap {

/**
* 根据经纬度获取详细地址
* @param longitude 经度
* @param latitude 纬度
* @return 详细地址信息
*/
@Get("/service/regeo")
Map getLocation(@Query("longitude") String longitude, @Query("latitude") String latitude);

}

... ...

Amap amp = Forest.client(Amap.class);
// 发送请求查询经纬度
Map locationInfo = amp.getLocation("32.1242832", "56.3290434");

自动 JSON 和 XML 转换

其实,我们处理 HTTP 的工作时,除了浪费在组装各种请求参数外,大部分时间都花了在序列化和反序列化各种格式的数据上,如 JSON 和 XML。

以前用 HttpClient,这些重复的机械性工作都要自己来搞,很是麻烦。

用 Forest 就方便多了,比如要 POST 一个 JSON 对象,直接挂个 @JSONBody 就好了,就是这么清爽。

1
2
3
4
java复制代码// 直接将 MyUserInfo 转换成 JSON
// 将服务端响应返回的 JSON 数据转换成 Result<Boolean> 类对象
@Post("http://localhost:8080/user")
Result<Booelean> createUser(@JSONBody MyUserInfo user);

和 Retrofit 以及 Feign 的比较

之前也用过这两块开源框架,都很强大,但各有优缺点。

Retrofit 的主要的问题时和 OkHttp 绑的太死,有些功能被 OkHttp 限制住了,比如我想处理 Get 请求传输 Body 数据这种非标准的 HTTP 请求就很难办到,而 Forest 可以随意切换 OkHttp 和 HttpClient 作为后端,需要用哪个时用哪个。

Retrofit 注解的丰富性也不如 Forest,比如要实现 HTTP 网络代理就要自己去写代码,而 Forest 提供了 @HTTPProxy 注解,设置一下就完事了。

如果要扩展自定义注解都是基于 OkHttp 的拦截器,不是特别方便,而 Forest 拦截器要比 OkHttp 的方便很多,提供 onInvoke, beforeExecute, onSccuess, onError 等回调方法,等于把一个请求的生老病死都涵盖了。

而 Feign 的问题则是和 Spring 绑的太紧,很多功能需要依赖 Spring 去做,太加了 Spring 相关包又太重了。

Forest 的核心包基本涵盖了所有 HTTP 所需功能和注解,不依赖 Spring,要轻量许多,但又不失方便性。

想要了解更多请参考:gitee.com/dromara/for…

LiteFlow

一款超轻量,快速,稳定,可编排的组件式流程引擎/规则引擎。

解耦复杂系统的神器!如果你正在为设计一个复杂系统感到头痛,那么LiteFlow是是你不二的选择,超低的学习成本,强大的编排功能,让你的系统变得更优雅!

官方网站:yomahub.com/liteflow

Gitee托管仓库:gitee.com/dromara/lit…

Github托管仓库:github.com/dromara/lit…

image.png

Liteflow为解耦复杂逻辑而生,如果你要对复杂业务逻辑进行新写或者重构,用liteflow最合适不过。它是一个轻量,快速的组件式流程引擎框架,组件编排,帮助解耦业务代码,让每一个业务片段都是一个组件。

使用Liteflow,你需要去把复杂的业务逻辑按代码片段拆分成一个个小组件,并定义一个规则流程配置。这样,所有的组件,就能按照你的规则配置去进行复杂的流转。同时Liteflow支持规则文件的热加载,即时完成修改生效。并提供多种持久化规则的方式的扩展。

使用LiteFLow,三大核心概念是组件,规则和上下文。

你需要这么像这样去定义你的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码//这里普通组件
@LiteflowComponent(id = "a", name = "组件A描述")
public class ACmp extends NodeComponent {
@Override
public void process() {
//do your business
}
}

//这是条件组件
@LiteflowComponent(id = "b", name = "组件B描述")
public class BCondCmp extends NodeCondComponent {
@Override
public String processCond() {
//do your business
return "e";
}
}

然后去定义你的规则,LiteFlow支持xml,yml,json三种格式,这里以xml形式为例

1
2
3
4
5
6
7
8
9
10
11
12
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<flow>
<chain name="chain1">
<then value="a,b(c|d|e)"/> <!-- c为路由组件,用来路由到c,d,e -->
<then value="sub_chain"/> <!-- 子流程 -->
</chain>

<chain name="sub_chain">
<when value="f,g,h"/> <!-- when代表并行 -->
<then value="j,k" /> <!-- then代表串行 -->
</chain>
</flow>

这样你的系统就会按照规则文件定义的方式,去执行你的业务组件。是不是很简单。

那规则文件定义在哪呢,LiteFlow并不限定你的规则文件来源,可以是本地文件,可以是注册中心,可以是任何数据库。LiteFlow提供了非常自由的接口供你扩展,你想存储在哪里都行。改变规则文件,即可实时刷新你的规则流程!如果你想做一个灵活度高,伸缩性强的系统,LiteFlow是不是非常适合呢。

LiteFlow为每一个请求都去开辟申请了一个Slot,你可以理解为上下文,所有的组件共享这个Slot。你可以在任意组件里通过对Slot的访问来获得任意数据,也可以存放任意数据。你也可以扩展Slot,自定义这个slot的属性。

1
2
3
4
5
6
7
8
java复制代码@LiteflowComponent(id = "a", name = "组件A描述")
public class ACmp extends NodeComponent {
@Override
public void process() {
Slot slot = this.getSlot();
//通过对slot的getData,setData,或者存取你自己扩展的slot属性
}
}

正因为有Slot的存在,才抹平了组件与组件之间的差异性,使得每一个业务组件之间无强依赖。这样的设计,就可以让你的系统高度自由化,组件复用,组件调换顺序得以方便的实现!

LiteFlow还支持2种脚本语言的接入,目前支持Groovy和QLExpress两种脚本语言。你可以在xml/yml/json定义脚本,以下以xml为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<flow>
<nodes>
<node id="s1" name="普通脚本" type="script">
<![CDATA[
def a=3;
def b=2;
slot.setData("s1",a*b);
]]>
</node>

<node id="s2" name="条件脚本" type="cond_script">
<![CDATA[
count = slot.getData("count");
if(count > 100){
return "a";
}else{
return "b";
}
]]>
</node>
</nodes>

<chain name="chain1">
<then value="a,b,c,s1"/>
</chain>

<chain name="chain2">
<then value="d,s2(a|b)"/>
</chain>
</flow>

那么在什么地方定义是哪种语言的脚本呢,LiteFlow的脚本功能是一个SPI机制的实现,你依赖了哪个脚本包,就以哪种脚本的方式执行。

有了脚本语言的支持,连业务代码是不是都可以热部署了?香不香?

LiteFlow的功能远不止这些,想了解更多,请到官网文档去查看了解。相信LiteFlow会让你感到优雅和惊艳。

想要了解更多请参考:yomahub.com/liteflow

JPom

一款简而轻的低侵入式在线构建、自动部署、日常运维、项目监控软件

中小公司团队DevOps的福音!轻量且强大,你不试试?

官方网站:jpom.io/

Gitee托管仓库:gitee.com/dromara/Jpo…

Github托管仓库:github.com/dromara/Jpo…

image.png

Jpom 是一款简而轻的低侵入式在线构建、自动部署、日常运维、项目监控软件

在中小公司或者团队中传统项目部署、运维流程通用的方法是登录服务器上传新的项目包,执行相应命令管理,如果管理多个项目则重复操作上述步骤。

市面上有很许多 DevOps 软件但是这些软件基本都会是难上手,重依赖。Jpom 则是针对中小公司或者团队设计的一款低侵入,轻依赖的一款轻量级的 DevOps 软件。

项目主要功能及特点

  1. 创建、修改、删除项目、Jar包管理
  2. 实时查看控制台日志、备份日志、删除日志、导出日志
  3. 在线构建项目发布项目一键搞定
  4. 多节点管理、多节点自动分发
  5. 在线 SSH 终端,并且有终端日志和禁用命令
  6. 实时监控项目状态异常自动报警
  7. cpu、ram 监控、导出堆栈信息、查看项目进程端口、服务器状态监控
  8. 多用户管理,用户项目权限独立(上传、删除权限可控制),完善的操作日志
  9. 系统路径白名单模式,杜绝用户误操作系统文件
  10. 在线管理 Nginx 配置文件、ssl 证书文件

一键安装(Linux)(推荐)

插件端

如果服务端也需要被管理,在服务端上也需要安装插件端

安装的路径位于执行命令目录(数据、日志存放目录默认位于安装路径,如需要修改参考配置文件:extConfig.yml )

1
2
3
4
5
6
7
8
9
ruby复制代码yum install -y wget && wget -O install.sh https://dromara.gitee.io/jpom/docs/install.sh && bash install.sh Agent

备用地址

yum install -y wget && wget -O install.sh https://cdn.jsdelivr.net/gh/dromara/Jpom/docs/install.sh && bash install.sh Agent

支持自动安装jdk环境

yum install -y wget && wget -O install.sh https://dromara.gitee.io/jpom/docs/install.sh && bash install.sh Agent jdk

启动成功后,插件端的端口为 2123

服务端

安装的路径位于执行命令目录(数据、日志存放目录默认位于安装路径,如需要修改参考配置文件:extConfig.yml )

如果需要修改数据、日志存储路径请参照 extConfig.yml 文件中 jpom.path 配置属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ruby复制代码yum install -y wget && wget -O install.sh https://dromara.gitee.io/jpom/docs/install.sh && bash install.sh Server

备用地址

yum install -y wget && wget -O install.sh https://cdn.jsdelivr.net/gh/dromara/Jpom/docs/install.sh && bash install.sh Server


支持自动安装jdk环境

yum install -y wget && wget -O install.sh https://dromara.gitee.io/jpom/docs/install.sh && bash install.sh Server jdk

支持自动安装jdk和maven环境

yum install -y wget && wget -O install.sh https://dromara.gitee.io/jpom/docs/install.sh && bash install.sh Server jdk+mvn

启动成功后,服务端的端口为 2122 访问管理页面 例如http://localhost:2122/

特别提醒:一键安装的时候注意执行命令不可在同一目录下,即Server端和Agent端不可安装在同一目录下

如无法访问,检查下是否开启了防火墙systemctl status firewalld,如状态显示为绿色Active: active (running)可临时关闭防火墙systemctl stop firewalld,然后重启防火墙firewall-cmd --reload(建议仅测试环境下使用,生产环境下慎用)
如关闭防火墙后仍无法访问,并且使用的是云服务器,还需要到云服务器管理后台中关闭防火墙

想要了解更多请参考:gitee.com/dromara/Jpo…

最后

以上推荐的开源项目,仅仅是Dromara Java社区里其中4个,Dromara社区还有很多更为优秀的开源项目。每一个项目都凝结着每个作者日日夜夜的心血和付出,他们用开放的心态去拥抱这个世界,用技术的力量为中国的开源事业添砖加瓦。

我们努力发着光,为了照亮别人,也为了明亮自我。

同时也希望有更多的Java开源作者能加入Dromara社区,凝聚吾辈之力,汇聚诸君之绩,披荆斩棘,同舟共济。

最后,看到这的童鞋,点赞,分享,在看点起来啊!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

看动画学算法之 doublyLinkedList 简介 do

发表于 2021-09-30

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

简介

今天我们来学习一下复杂一点的LinkedList:doublyLinkedList。

和LinkedList相比,doublyLinkedList中的节点除了next指向下一个节点之外,还有一个prev之前的一个节点。所以被称为doublyLinkedList。 doublyLinkedList是一个双向链表,我们可以向前或者向后遍历list。

今天我们来学习一下doublyLinkedList的基本操作和概念。

doublyLinkedList的构建

和linkedList一样,doublyLinkedList是由一个一个的节点构成的。而每个节点除了要存储要保存的数据之外,还需要存储下一个节点和上一个节点的引用。

doublyLinkedList需要一个head节点,我们看下怎么构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class DoublyLinkedList {

Node head; // head 节点

//Node表示的是Linked list中的节点,包含一个data数据,上一个节点和下一个节点的引用
class Node {
int data;
Node next;
Node prev;
//Node的构造函数
Node(int d) {
data = d;
}
}
}

doublyLinkedList的操作

接下来,我们看一下doublyLinkedList的一些基本操作。

头部插入

头部插入的逻辑是:将新插入的节点作为新的head节点,并且将newNode.next指向原来的head节点。

同时需要将head.prev指向新的插入节点。

看下java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码    //插入到linkedList的头部
public void push(int newData) {
//构建要插入的节点
Node newNode = new Node(newData);
//新节点的next指向现在的head节点
//新节点的prev指向null
newNode.next = head;
newNode.prev = null;

if (head != null)
head.prev = newNode;

//现有的head节点指向新的节点
head = newNode;
}

尾部插入

尾部插入的逻辑是:找到最后一个节点,将最后一个节点的next指向新插入的节点,并且将新插入的节点的prev指向最后一个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码   //新节点插入到list最后面
public void append(int newData) {
//创建新节点
Node newNode = new Node(newData);
//如果list是空,则新节点作为head节点
if (head == null) {
newNode.prev = null;
head = newNode;
return;
}

newNode.next = null;
//找到最后一个节点
Node last = head;
while (last.next != null) {
last = last.next;
}
//插入
last.next = newNode;
newNode.prev = last;
return;
}

插入给定的位置

如果要在给定的位置插入节点,我们需要先找到插入位置的前一个节点,然后将前一个节点的next指向新节点。新节点的prev指向前一个节点。

同时我们需要将新节点的next指向下一个节点,下一个节点的prev指向新的节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码    //插入在第几个元素之后
public void insertAfter(int index, int newData) {
Node prevNode = head;
for (int i = 1; i < index; i++) {
if (prevNode == null) {
System.out.println("输入的index有误,请重新输入");
return;
}
prevNode = prevNode.next;
}
//创建新的节点
Node newNode = new Node(newData);
//新节点的next指向prevNode的下一个节点
newNode.next = prevNode.next;
//将新节点插入在prevNode之后
prevNode.next = newNode;
//将新节点的prev指向prevNode
newNode.prev = prevNode;

//newNode的下一个节点的prev指向newNode
if (newNode.next != null)
newNode.next.prev = newNode;
}

删除指定位置的节点

删除节点的逻辑是:找到要删除节点的前一个节点,和下一个节点。前一个节点的next指向下一个节点,下一个节点的prev指向前一个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码    //删除特定位置的节点
void deleteNode(int index)
{
// 如果是空的,直接返回
if (head == null)
return;

// head节点
Node temp = head;

// 如果是删除head节点
if (index == 1)
{
head = temp.next;
return;
}

// 找到要删除节点的前一个节点
for (int i=1; temp!=null && i<index-1; i++)
temp = temp.next;

// 如果超出范围
if (temp == null || temp.next == null)
return;

// temp->next 是要删除的节点,删除节点
Node next = temp.next.next;
temp.next = next;
next.prev=temp;
}

本文的代码地址:

learn-algorithm

本文收录于 www.flydean.com/algorithm-d…

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

为什么 HashMap 是线程不安全的

发表于 2021-09-30

这是《Java 程序员进阶之路》专栏的第 58 篇,我们来聊聊为什么 HashMap 是线程不安全的。

01、多线程下扩容会死循环

众所周知,HashMap 是通过拉链法来解决哈希冲突的,也就是当哈希冲突时,会将相同哈希值的键值对通过链表的形式存放起来。

JDK 7 时,采用的是头部插入的方式来存放链表的,也就是下一个冲突的键值对会放在上一个键值对的前面(同一位置上的新元素被放在链表的头部)。扩容的时候就有可能导致出现环形链表,造成死循环。

resize 方法的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码// newCapacity为新的容量
void resize(int newCapacity) {
// 小数组,临时过度下
Entry[] oldTable = table;
// 扩容前的容量
int oldCapacity = oldTable.length;
// MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30
if (oldCapacity == MAXIMUM_CAPACITY) {
// 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1
threshold = Integer.MAX_VALUE;
return;
}

// 初始化一个新的数组(大容量)
Entry[] newTable = new Entry[newCapacity];
// 把小数组的元素转移到大数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 引用新的大数组
table = newTable;
// 重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer 方法用来转移,将小数组的元素拷贝到新的数组中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码void transfer(Entry[] newTable, boolean rehash) {
// 新的容量
int newCapacity = newTable.length;
// 遍历小数组
for (Entry<K,V> e : table) {
while(null != e) {
// 拉链法,相同 key 上的不同值
Entry<K,V> next = e.next;
// 是否需要重新计算 hash
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 根据大数组的容量,和键的 hash 计算元素在数组中的下标
int i = indexFor(e.hash, newCapacity);

// 同一位置上的新元素被放在链表的头部
e.next = newTable[i];

// 放在新的数组上
newTable[i] = e;

// 链表上的下一个元素
e = next;
}
}
}

注意 e.next = newTable[i] 和 newTable[i] = e 这两行代码,就会将同一位置上的新元素被放在链表的头部。

扩容前的样子假如是下面这样子。

那么正常扩容后就是下面这样子。

假设现在有两个线程同时进行扩容,线程 A 在执行到 newTable[i] = e; 被挂起,此时线程 A 中:e=3、next=7、e.next=null

线程 B 开始执行,并且完成了数据转移。

此时,7 的 next 为 3,3 的 next 为 null。

随后线程A获得CPU时间片继续执行 newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:

执行下一轮循环,此时 e=7,原本线程 A 中 7 的 next 为 5,但由于 table 是线程 A 和线程 B 共享的,而线程 B 顺利执行完后,7 的 next 变成了 3,那么此时线程 A 中,7 的 next 也为 3 了。

采用头部插入的方式,变成了下面这样子:

好像也没什么问题,此时 next = 3,e = 3。

进行下一轮循环,但此时,由于线程 B 将 3 的 next 变为了 null,所以此轮循环应该是最后一轮了。

接下来当执行完 e.next=newTable[i] 即 3.next=7 后,3 和 7 之间就相互链接了,执行完 newTable[i]=e 后,3 被头插法重新插入到链表中,执行结果如下图所示:

套娃开始,元素 5 也就成了弃婴,惨~

不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序,参照HashMap 扩容机制的这一篇。

02、多线程下 put 会导致元素丢失

正常情况下,当发生哈希冲突时,HashMap 是这样的:

但多线程同时执行 put 操作时,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。

put 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
java复制代码final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;

// 步骤①:tab为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

// 步骤②:计算index,并对null做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;

// 步骤③:节点key存在,直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;

// 步骤④:判断该链为红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

// 步骤⑤:该链为链表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);

//链表长度大于8转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}

// key已经存在直接覆盖value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}

// 步骤⑥、直接覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;

// 步骤⑦:超过最大容量 就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

问题发生在步骤 ② 这里:

1
2
java复制代码if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

两个线程都执行了 if 语句,假设线程 A 先执行了 tab[i] = newNode(hash, key, value, null),那 table 是这样的:

接着,线程 B 执行了 tab[i] = newNode(hash, key, value, null),那 table 是这样的:

3 被干掉了。

03、put 和 get 并发时会导致 get 到 null

线程 A 执行put时,因为元素个数超出阈值而出现扩容,线程B 此时执行get,有可能导致这个问题。

注意来看 resize 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
java复制代码final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
}

线程 A 执行完 table = newTab 之后,线程 B 中的 table 此时也发生了变化,此时去 get 的时候当然会 get 到 null 了,因为元素还没有转移。


为了便于大家更系统化地学习 Java,二哥已经将《Java 程序员进阶之路》专栏开源到 GitHub 上了,大家只需轻轻地 star 一下,就可以和所有的小伙伴一起打怪升级了。

GitHub 地址:github.com/itwanger/to…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java基础:为什么需要用stringbuffer或者Str

发表于 2021-09-30

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

📖前言

1
复制代码心态好了,就没那么累了。心情好了,所见皆是明媚风景。

**`“一时解决不了的问题,那就利用这个契机,看清自己的局限性,对自己进行一场拨乱反正。”正如老话所说,一念放下,万般自在。如果你正被烦心事扰乱心神,不妨学会断舍离。断掉胡思乱想,社区垃圾情绪,离开负面能量。心态好了,就没那么累了。心情好了,所见皆是明媚风景。


简述

首先说运行速度,或者说是执行速度,在这方面运行速度快慢为:StringBuilder > StringBuffer > String

  1. String最慢的原因:

String为字符串常量,而StringBuilder和StringBuffer均为字符串变量,即String对象一旦创建之后该对象是不可更改的,但后两者的对象是变量,是可以更改的。以下面一段代码为例:

  1. 而StringBuilder和StringBuffer的对象是变量,对变量进行操作就是直接对该对象进行更改,而不进行创建和回收的操作,所以速度要比String快很多
  1. 在线程安全上,StringBuilder是线程不安全的,而StringBuffer是线程安全的 如果一个StringBuffer对象在字符串缓冲区被多个线程使用时,StringBuffer中很多方法可以带有synchronized关键字,所以可以保证线程是安全的,但StringBuilder的方法则没有该关键字,所以不能保证线程安全,有可能会出现一些错误的操作。所以如果要进行的操作是多线程的,那么就要使用StringBuffer,但是在单线程的情况下,还是建议使用速度比较快的StringBuilder。

直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
java复制代码package com.test;

/**
*
* @Description: 为什么需要用stringbuffer或者StringBuilder去拼接字符串——而不用string——以及stringbuffer的基本了解
* 在线程安全上,StringBuilder是线程不安全的,而StringBuffer是线程安全的
* 如果一个StringBuffer对象在字符串缓冲区被多个线程使用时,StringBuffer中很多方法可以带有synchronized关键字,
* 所以可以保证线程是安全的,但StringBuilder的方法则没有该关键字,所以不能保证线程安全,有可能会出现一些错误的操作。
* 所以如果要进行的操作是多线程的,那么就要使用StringBuffer,但是在单线程的情况下,还是建议使用速度比较快的StringBuilder。
*
* stringbuilder类的append方法是return this,而string类的方法都是return new String
*
* 因为string使用final char[] value数组存储字符串内容,每次修改是return new String返回一个新的字符串,需要重新生成一个字符串对象,
* 申请内存空间,这花了时间,而stringbuilder类的value数组不是final的,是可变的,不需要重新生成新的对象,但是数组扩容其实也是换一个更大的数组罢了
*
* @ClassName: Test.java
* @author ChenYongJia
* @Date 2019年6月26日 晚上20:23
* @Email chen87647213@163.com
*/
public class Test {

public static void main(String[] args) {
String str = "";
StringBuffer sb = new StringBuffer();// 线程安全
StringBuilder sbBuilder = new StringBuilder();// 线程不安全
long start = 0L;
long end = 0L;
start = System.currentTimeMillis();
for (int i = 0; i < 99999; i++) {// 进行十万次循环
str = str + "我们来测试一下试试看看差异有多大!";
}
end = System.currentTimeMillis();
System.out.println("使用string的时间是:" + (end - start) + "毫秒!");

start = System.currentTimeMillis();
for (int i = 0; i< 99999; i++) {// 进行十万次循环
sb.append("我们来测试一下试试看看差异有多大!");
}
end = System.currentTimeMillis();
System.out.println("使用StringBuffer的时间是:" + (end - start) + "毫秒!");

start = System.currentTimeMillis();
for (int i = 0; i < 99999; i++) {// 进行十万次循环
sbBuilder.append("我们来测试一下试试看看差异有多大!");
}
end = System.currentTimeMillis();
System.out.println("使用StringBuilder的时间是:" + (end - start) + "毫秒!");
}

}

我们看执行结果最直观

在这里插入图片描述
String拼接了223秒,另外两个是毫秒级别,不用我多说了吧各位


总结

  • String:适用于少量的字符串操作的情况
  • StringBuilder:适用于单线程下在字符缓冲区进行大量操作的情况
  • StringBuffer:适用多线程下在字符缓冲区进行大量操作的情况
  • stringbuilder类的append方法是return this,而string类的方法都是return new String
  • 因为string使用final char[] value数组存储字符串内容,每次修改是return new String返回一个新的字符串,需要重新生成一个字符串对象,申请内存空间,这花了时间,而stringbuilder类的value数组不是final的,是可变的,不需要重新生成新的对象,但是数组扩容其实也是换一个更大的数组罢了

最后感谢大家耐心观看完毕,原创不易,留个点赞收藏是您对我最大的鼓励!


🎉总结:

  • 更多参考精彩博文请看这里:《陈永佳的博客》
  • 喜欢博主的小伙伴可以加个关注、点个赞哦,持续更新嘿嘿!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Jenkinsfile 入门指南

发表于 2021-09-30

本人也刚刚入门学习Jenkinsfile,本文给出简单的使用说明,不正确的步骤望大佬指教。

一、Blue Ocean 安装

在插件库安装

image.png

重启jenkins
二、工程创建

image.png
三、第一个Jenkinsfile

由于Jenkinsfile支持groovy语法,所以下面有用该方式写的一些工具文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
php复制代码/**
*
*/
return this

/**
* 拉取代码
* @param gitUrl
* @param gitBranch
* @param credentialsId
* @return
*/
def gitClone(String gitUrl, String gitBranch, String credentialsId='11eb2bd6-050b-4853-9dd6-8118a8d3e370') {
checkout([
$class: 'GitSCM',
branches: [[name: gitBranch]],
doGenerateSubmoduleConfigurations: false,
extensions: [[
$class: 'SubmoduleOption',
disableSubmodules: false,
parentCredentials: true,
recursiveSubmodules: true,
reference: '',
trackingSubmodules: false
]],
submoduleCfg: [],
userRemoteConfigs: [[credentialsId: credentialsId, url: gitUrl]]
])
}

/**
* ssh发布 如果有文件上传,则会上传到对应用户的根目录下
* @param ip
* @param sourceFiles
* @param remoteDirectory
* @param removePrefix
* @param execCommand 远程执行命令
* @return
*/
def sshPublish(String ip,String sourceFiles,String remoteDirectory,String removePrefix,String execCommand){
sshPublisher(
publishers: [
sshPublisherDesc(
configName: '192.168.137.100',
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: 'ls',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: '/temp/springboot-ci/',
remoteDirectorySDF: false,
removePrefix: 'target',
sourceFiles: 'target/*.jar'
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: true
)
]
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
python复制代码pipeline {
agent any
environment {
APP_VERSION = ''
APP_DIR = 'APP_WORKSPACE'
APP_WORKSPACE = '${WORKSPACE}/${APP_DIR}'
COMMIT_MSG = ''
APP_CODE = """${sh(
returnStdout: true,
script: 'echo ${SYSTEM_NAME#*:}'
).trim()}"""
APP_QCBD = """${sh(
returnStdout: true,
script: 'echo ${QCBD#*:}'
).trim()}"""
}
parameters {
choice(name: 'SYSTEM_NAME', choices: ['门店系统:sms', '运维系统:cos'], description:'请选择服务')
string(name: 'BRANCH', defaultValue: '', description: '分支名')
choice(name: 'QCBD', choices: ['普通发布:NO', '快速发布:YES'], description:'发布类型')
}
post {
always {
pwd()
}
}
stages {
stage('Init') {
steps {
script {
utils = load "./script/utils.groovy"
utils.gitClone('http://192.168.137.200/CI/springboot-ci.git','*/${BRANCH}')
}
}
}
stage('Build') {
steps {
script {
echo 'Building..'
sh "pwd"
if(env.APP_QCBD == 'YES'){
echo "执行快速发布不打包,利用上次打包后的文件"
}else{
sh '/usr/local/maven/bin/mvn clean package -Dmaven.test.skip=true'
}
}
}
}
stage('Deploy') {
steps {
echo 'Deploying....'
script{
utils = load "./script/utils.groovy"
utils.sshPublish('192.168.137.100','target/*.jar','/temp/springboot-ci/','target','ls')
}
}
}
}
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java 面试八股文之基础篇(一)

发表于 2021-09-30

前言

从今天开始,我将开启一个系列的文章——【 Java 面试八股文】。

这个系列会陆续更新 Java 面试中的高频问题,旨在从问题出发,理解 Java 基础,数据结构与算法,数据库,常用框架等。

首先要做几点说明:

  1. 【 Java 面试八股文】中的面试题来源于社区论坛,书籍等资源;感谢使我读到这些宝贵的面经的作者们。
  2. 对于【 Java 面试八股文】中的每个问题,我都会尽可能地写出我自己认为的“完美解答”。但是毕竟我的身份不是一个“真理持有者”,只是一个秉承着开源分享精神的 “knowledge transmitter” & 菜鸡,所以,如果这些答案出现了错误,可以留言写出你认为更好的解答,并指正我。非常感谢您的分享。
  3. 知识在于“融释贯通”,而非“死记硬背”;现在市面上固然有很多类似于“Java 面试必考 300 题” 这类的文章,但是普遍上都是糟粕,仅讲述其果,而不追其源;希望我的【 Java 面试八股文】可以让你知其然,且知其所以然~

那么,我们正式开始吧!

Java 基础篇(一)

1、分析程序的运行结果,并解释为什么?


程序一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public class MyTestClass {
private static MyTestClass myTestClass = new MyTestClass();

private static int a = 0;
private static int b;

private MyTestClass() {
a++;
b++;
}

public static MyTestClass getInstance() {
return myTestClass;
}

public int getA() {
return a;
}

public int getB() {
return b;
}
}
1
2
3
4
5
6
7
8
9
java复制代码public class Test {

public static void main(String[] args) {
MyTestClass myTestClass = MyTestClass.getInstance();
System.out.println("myTestClass.a : " + myTestClass.getA());
System.out.println("myTestClass.b : " + myTestClass.getB());

}
}

程序二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class MyTestClass2 {

private static int a = 0;
private static int b;

private MyTestClass2(){
a++;
b++;
}

private static final MyTestClass2 myTestClass2 = new MyTestClass2();

public static MyTestClass2 getInstance(){
return myTestClass2;
}

public int getA() {
return a;
}

public int getB() {
return b;
}
}
1
2
3
4
5
6
7
java复制代码public class Test {
public static void main(String[] args) {
MyTestClass2 myTestClass2 = MyTestClass2.getInstance();
System.out.println("myTestClass2.a : " + myTestClass2.getA());
System.out.println("myTestClass2.b : " + myTestClass2.getB());
}
}
答

第一个程序执行的结果为:

1
2
plain复制代码myTestClass.a : 0
myTestClass.b : 1

第二个程序执行的结果为:

1
2
plain复制代码myTestClass2.a : 1
myTestClass2.b : 1

本题考查的知识点为【类加载的顺序】。一个类从被加载至 JVM 到卸载出内存的整个生命周期为:

各个阶段的主要功能如下:

  • 加载:查找并加载类文件的二进制数据
  • 链接:将已经读入内存的类的二进制数据合并到 JVM 的运行时环境中去,包含如下几个步骤:
+ 验证:确保被加载类的正确性
+ 准备:为类的**静态变量**分配内存,赋默认值;例如:`public static int a = 1;` 在准备阶段对静态变量 a 赋默认值 0
+ 解析:把常量池中的符号引用转换成直接引用
  • 初始化:为类的静态变量赋初始值;例如:public static int a = 1;这个时候才对静态变量 a 赋初始值 1

我们可以从 Java 类加载的这个过程中看到,类的静态变量在类加载时就已经被加载到内存中并完成赋值了!

对于第一个程序来说:

首先,在链接的准备阶段,JVM 会为类的静态变量分配内存,并赋默认值,这里面我们也可以使用更加专业的计算机词汇——“缺省值”来形容,即:

1
2
3
java复制代码myTestClass = null;
a = 0;
b = 0;

接着,在类的初始化阶段,JVM 会为这些静态变量真正地赋初始值。

1
java复制代码private static MyTestClass myTestClass = new MyTestClass();

对静态变量 myTestClass 赋初始值时会回调构造器,构造器中执行 a++ 与 b++,使得静态变量 a 与 b 的结果均为 1 。

对 myTestClass 这个静态变量赋值完毕后,接下来代码会继续执行,对 a 和 b 这两个静态变量赋初始值,继而又将 a 变为了 0,而 b 则没有初始值,所以其结果仍然为 1。

综上所示,程序一的输出结果为:

1
2
java复制代码myTestClass.a : 0
myTestClass.b : 1

程序二的分析过程和程序一是一样的,这里我就不再赘述了。

总结

本题考查的知识点是童鞋们对类加载的理解。一定要铭记的是:静态变量的加载与初始化发生在【类加载阶段】。

2、普通内部类与静态内部类有什么区别?


答

普通内部类:

  • 可以访问外部类的所有属性和方法
  • 普通内部类中不能包含静态的属性和方法

静态内部类:

  • 静态内部类只能访问外部类的静态属性及方法,无法访问外部类的普通成员(变量和方法)
  • 静态内部类可以包含静态的属性和方法

本题回答到这里并非完美,面试官可能会继续提问:你知道为什么普通内部类可以访问到外部类的成员变量么?或者是:我应该优先选用普通内部类还是静态内部类,为什么?

我们先来看一个示例:

Home

1
2
3
4
5
6
7
8
java复制代码package com.github.test;

public class Home {

class A {

}
}

Home2

1
2
3
4
5
6
7
8
java复制代码package com.github.test;

public class Home2 {

static class A {

}
}

执行编译后,我们来到 target 目录下,并执行反编译命令:

1
arduino复制代码javap -private 'Home$A'

Home$A.class

1
2
3
4
java复制代码class com.github.test.Home$A {
final com.github.test.Home this$0;
com.github.test.Home$A(com.github.test.Home);
}

执行命令:

1
arduino复制代码javap -private 'Home2$A'

Home2$A.class

1
2
3
java复制代码class com.github.test.Home2$A {
com.github.test.Home2$A();
}

我们可以看到 Home 类当中含有普通内部类 A,而 Home2 这个类中含有静态内部类 A 。并且我们对这两个内部类执行了反解析。

执行javap命令后,我们看到普通内部类 A 比静态内部类 A 多了一个特殊的字段:com.github.test.Home this$0。

普通内部类多出的这个字段是 JDK “偷偷”为我们添加的,它指向了外部类 Home。

所以,我们也就搞清楚了,之所以普通内部类可以直接访问外部类的所有成员,是因为 JDK 为普通内部类偷偷添加了这么一个隐式的变量 this$0,指向外部类。

那么,我们应该优先选择普通内部类还是静态内部类呢?

《Effective java》 Item 24 的内容是:Favor static member classes over nonstatic,即:优先考虑使用静态内部类。

因为非静态内部类会持有外部类的一个隐式引用(this$0), 存储这个引用需要占用时间和空间。更严重的是有可能会导致宿主类在满足垃圾回收的条件时却仍然驻留在内存中,由此引发内存泄漏的问题。

所以,在需要使用内部类的情况下,我们应该尽可能选择使用静态内部类。

总结

怎么样?一道看似非常简答的问题也可能暗藏杀机。如果不知道的小伙伴们,不妨敲一下代码,自己按照流程执行一遍,这样才会加深你的印象哦~

3、分析程序的运行结果,并解释为什么?


程序一:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class Polymorphic {
public static void main(String[] args) {
Animal cat = new Cat();
System.out.println(cat.name);
}
}
class Animal {
String name = "animal";
}
class Cat extends Animal{
String name = "cat";
}

程序二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public class Polymorphic {
public static void main(String[] args) {
Animal cat = new Cat();
cat.speak();
}
}
class Animal {
public void speak(){
System.out.println("我是一个动物");
}
}
class Cat extends Animal{
@Override
public void speak() {
System.out.println("我是一只猫");
}
}
答

程序一的输出结果为:

1
复制代码animal

程序二的输出结果为:

1
复制代码我是一只猫

本题考查的知识点为多态。需要知道,多态分为编译时的多态性与运行时的多态性。

  • 多态的应用中,对于成员变量访问的特点为:
    • 编译看左边,运行看左边
  • 多态的应用中,对于成员方法调用的特点为:
    • 编译看左边,运行看右边

对于程序一,在程序编译时期,首先 JVM 会看向 Animal cat = new Cat(); 这句话等号左边的父类 Animal 是否有该变量(name)的定义,如果有则编译成功,如果没有则编译失败;在程序运行时期,对于成员变量,JVM 仍然会看向左边的所属类型,获取的是父类的成员变量。

对于程序二,在程序编译时期,首先 JVM 会看向 Animal cat = new Cat(); 这句话等号左边的类是否有该方法的定义,如果有则编译成功,如果没有则编译失败;在程序运行时,则是要看等号右边的对象是如何实现该方法的,所以最终呈现的结果为右边对象对这个方法重写后的结果。

总结

这是一道非常经典(老掉牙)的面试笔试题了,考察 Java 多态的基础,答错的小伙伴可要好好回顾复习下了~

4、请谈一下值传递与引用传递?Java 中只有值传递么?


答

值传递(Pass by value)与引用传递(Pass by reference)属于函数调用时,参数的求值策略(Evaluation Strategy)。求值策略的关注点在于,求值的时间以及传值方式:

求值策略 求值时间 传值方式
Pass by value 函数调用前 原值的副本
Pass by reference 函数调用前 原值(原始对象)

所以,区别值传递与引用传递的实质并不是传递的类型是值还是引用,而是传值方式,传递的是原值还是原值的副本。

如果传递的是原值(原对象),就是引用传递;如果传递的是一个副本(拷贝),就是值传递。再次强调一遍,值传递和引用传递的区别在于传值方式,和你传递的类型是值还是引用没有一毛钱关系!

Java 语言只有值传递。

Java 语言之所以只有值传递,是因为:传递的类型无论是值类型还是引用类型,Java 都会在调用栈上创建一个副本,不同的是,对于值类型而言,这个副本就是整个原始值的复制;而对于引用类型而言,由于引用类型的实例存储在堆中,在栈上只有它的一个引用,指向堆的实例,其副本也只是这个引用的复制,而不是整个原始对象的复制。

我们通过两个程序来理解下:

程序一:

1
2
3
4
5
6
7
8
9
10
java复制代码public class Test {
public static void setNum1(int num){
num = 1;
}
public static void main(String[] args) {
int a = 2;
setNum1(a);
System.out.println(a);
}
}

程序二:

1
2
3
4
5
6
7
8
9
10
java复制代码public class Test2 {
public static void setArr1(int[] arr){
Arrays.fill(arr,1);
}
public static void main(String[] args) {
int[] arr = {1,2,3,4,5};
setArr1(arr);
System.out.println(Arrays.toString(arr));
}
}

程序一输出的结果为:2;
程序二输出的结果为:[1,1,1,1,1]。

程序一中,Java 会将原值复制一份放在栈区,并将这个拷贝传递到方法参数中,方法里面仅仅是对这个拷贝进行了修改,并没有影响到原值,所以程序一的输出结果为 2。

程序二中,Java 会将引用的地址复制一份放在栈区,复制的拷贝和原始引用都指向堆区的同一个对象。方法通过拷贝地址找到堆区的实例,对堆区的实例进行修改,而此时,原始引用仍然指向着堆区的实例,所以程序二的输出结果为:[1,1,1,1,1]

总结

这实际上也是一个老掉牙的问题了。

不过,请不要忽视它,它也许没有你想的那么简单。绝大部分初学者很难搞懂究竟什么是值传递,什么是引用传递。

很多博客中,作者不仅没有解释清楚“值传递”与“引用传递”,还混淆了很多错误的引导。

这些错误的理解包括:

  • 【观点1】Java 中既有值传递也有引用传递
  • 【观点2】Java 中只有值传递,因为引用的本质就是指向堆区的一个地址,也是一个值。

如果你的观点符合上述两种观点的其中一种,那么你多半没有理解值传递和引用传递到底是啥子东西~

5、请描述当我们 new 一个对象时,发生了什么?


答

new 一个对象时,可以将发生的活动分为以下的几个过程:

  1. 类加载
  2. 为对象分配内存空间
  3. 完善对象内存布局信息
  4. 调用对象的实例化方法<init>
  5. 在栈中新建对象的引用,并指向堆中的实例
类加载

当 JVM 遇到一条 new 指令时,首先会去检查该指令的参数是否能在常量池中定位到一个类的符号引用(Symbolic Reference),并检查这个符号引用代表的类是否已经被加载,解析,初始化过。如果该类是第一次被使用,那么就会执行类的加载过程。

注:符号引用是指,一个类中引入了其他的类,可是 JVM 并不知道引入其他类在什么位置,所以就用唯一的符号来代替,等到类加载器去解析时,就会使用符号引用找到引用类的具体地址,这个地址就是直接引用

类的加载过程在上文中已经有提过,我们再不厌其烦地复习一下:

一个类从被加载至 JVM 到卸载出内存的整个生命周期为:

各个阶段的主要功能如下:

  • 加载:查找并加载类文件的二进制数据
  • 链接:将已经读入内存的类的二进制数据合并到 JVM 的运行时环境中去,包含如下几个步骤:
+ 验证:确保被加载类的正确性
+ 准备:为类的**静态变量**分配内存,赋默认值;例如:`public static int a = 1;` 在准备阶段对静态变量 a 赋默认值 0
+ 解析:把常量池中的符号引用转换成直接引用
  • 初始化:为类的静态变量赋初始值;例如:public static int a = 1;这个时候才对静态变量 a 赋初始值 1

谈到了类加载,就不得不提类加载器(ClassLoader)。

以 HotSpot VM 举例,从 JDK 9 开始,其自带的类加载器如下:

  • BootstrapClassLoader
  • PlatformClassLoader
  • AppClassLoader

而 JDK 8 虚拟机自带的加载器为:

  • BootstrapClassLoader
  • ExtensionClassLoader
  • AppClassLoader

除了虚拟机自带的类加载器以外,用户也可以自定义类加载器(UserClassLoader)。

这些类加载器的加载顺序具有一定的层级关系:

JVM 中的 ClassLoader 会按照这样的层级关系,采用一种叫做双亲委派模型的方式去加载一个类:

那么什么是双亲委派模型呢?

双亲委托模型就是:如果一个类加载器(ClassLoader)收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器(BootstrapClassLoader)中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。

使用双亲委托机制的好处是:能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。

为对象分配内存空间

在类加载完成后,JVM 就可以完全确定 new 出来的对象的内存大小了,接下来,JVM 会执行为该对象分配内存的工作。

为对象分配空间的任务等同于把一块确定大小的内存从 JVM 堆中划分出来,目前常用的有两种方式(根据使用的垃圾收集器的不同而使用不同的分配机制):

  • Bump the Pointer(指针碰撞)
  • Free List(空闲列表)

所谓的指针碰撞是指:假设 JVM 堆内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一半,中间有一个指针指向分界点,那新的对象分配的内存就是把那个指针向空闲空间挪动一段与对象大小相等的距离。

而如果 JVM 堆内存并不是规整的,即:已用内存空间与空闲内存相互交错,JVM 会维护一个空闲列表,记录哪些内存块是可用的,在为该对象分配空间时,JVM 会从空闲列表中找到一块足够大的空间划分给对象使用。

完善对象内存布局信息

在我们为对象分配好内存空间后,JVM 会设置对象的内存布局的一些信息。

对象在内存中存储的布局(以 HotSpot 虚拟机为例)分为:对象头,实例数据以及对齐填充。

  • 对象头

对象头包含两个部分:

+ Mark Word:存储对象自身的运行数据,如:Hash Code,GC 分代年龄,锁状态标志等等
+ 类型指针:对象指向它的类的元数据的指针
  • 实例数据
+ 实例数据是真正存放对象实例的地方
  • 对齐填充
+ 这部分不一定存在,也没有什么特别含义,仅仅是占位符。因为 HotSpot 要求对象起始地址都是 8 字节的整数倍,如果不是就对齐

JVM 会为所有实例数据赋缺省值,例如整型的缺省值为 0,引用类型的缺省值为 null 等等。

并且,JVM 会为对象头进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的 Hash Code,对象的 GC 分带年龄等等,这些信息都存放在对象的对象头中。

调用对象的实例化方法 <init>

在 JVM 完善好对象内存布局的信息后,会调用对象的 <init> 方法,根据传入的属性值为对象的变量赋值。

我们在上面介绍了类加载的过程(加载 -> 链接 -> 初始化),在初始化这一步骤,JVM 为类的静态变量进行赋值,并且执行了静态代码块。实际上这一步骤是由 JVM 生成的 <clinit> 方法完成的。

<clinit> 的执行的顺序为:

  1. 父类静态变量初始化
  2. 父类静态代码块
  3. 子类静态变量初始化
  4. 子类静态代码块

而我们在创建实例 new 一个对象时,会调用该对象类构造器进行初始化,这里面就会执行 <init> 方法。

<init>的执行顺序为:

  1. 父类变量初始化
  2. 父类普通代码块
  3. 父类构造函数
  4. 子类变量初始化
  5. 子类普通代码块
  6. 子类构造函数

关于<init> 方法:

  1. 有多少个构造器就会有多少个 <init> 方法
  2. <init> 具体执行的内容包括非静态变量的赋值操作,非静态代码块的执行,与构造器的代码
  3. 非静态代码赋值操作与非静态代码块的执行是从上至下顺序执行,构造器在最后执行

关于 <clinit> 与 <init> 方法的差异:

  1. <clinit>方法在类加载的初始化步骤执行,<init> 在进行实例初始化时执行
  2. <clinit> 执行静态变量的赋值与执行静态代码块,而<init> 执行非静态变量的赋值与执行非静态代码块以及构造器
在栈中新建对象的引用,并指向堆中的实例

这一点没什么好解释的,我们是通过操作栈的引用来操作一个对象的。

总结

如果可以这么详细地将 new 一个对象的过程表达出来,这个回答我想应该是满分了。其实也不难,我们只需要记住,new 一个对象可以分为:

  1. 类加载
  2. 为对象分配内存空间
  3. 完善对象内存布局信息
  4. 调用对象的实例化方法<init>
  5. 在栈中新建对象的引用,并指向堆中的实例

以上这五个步骤,并对每个步骤进行细分与归纳即可~

6、Java 对象的访问方式有哪些?


答

在 JVM 规范中只规定了 reference 类型是一个指向对象的引用,但没有规定这个引用具体如何去定位,访问堆中对象,因此对象的访问取决于 JVM 的具体实现,目前主流的访问对象的方式有两种:句柄间接访问 与 直接指针访问。

句柄间接访问

所谓的句柄间接访问是指,JVM 堆中会划分一块内存来作为句柄池,reference 中存储句柄的地址,句柄中则存储对象的实例数据以及类的元数据的地址,所以我们通过访问句柄进而达到访问对象的目的。

句柄的英文是 “Handle”。这个词的翻译最早追述于 David Gries所著的《Compiler Construction for Digital Computer》(1971)有句话 “A handle of any sentential form is a leftmost simple phrase.” 。该书中译本,《数字计算机的编译程序构造》(仲萃豪译, 1976 版)翻译成 “任一句型的句柄就是此句型的最左简单短语”。

直接指针访问

直接指针访问对象的方式为:JVM 堆中会存放访问访问类的元数据的地址,reference存储的是对象实例的地址:

我们看到,通过句柄访问对象使用的是一种间接引用(2次引用)的方式来进行访问堆内存的对象,它导致的缺点是运行的速度稍微慢一些;通过直接指针的方式则速度快一些,因为它少了一次指针定位的开销,所以,当前最主流的 JVM: HotSpot 采用的就是直接指针这种方式来访问堆区的对象。

总结

本题考查的是 JVM 比较基础的问题,看示意图就非常容易理解哦~

7、分析程序的运行结果,并解释为什么?


程序一:

1
2
3
4
5
6
7
java复制代码public class Main {
public static void main(String[] args) {
int a = 1000;
int b = 1000;
System.out.println(a == b);
}
}

程序二:

1
2
3
4
5
6
7
java复制代码public class Main {
public static void main(String[] args) {
Integer a = 1000;
Integer b = 1000;
System.out.println(a == b);
}
}

程序三:

1
2
3
4
5
6
7
java复制代码public class Main {
public static void main(String[] args) {
Integer a = 1;
Integer b = 1;
System.out.println(a == b);
}
}

程序四:

1
2
3
4
5
6
7
java复制代码public class Main {
public static void main(String[] args) {
Integer a = new Integer(1);
Integer b = new Integer(1);
System.out.println(a == b);
}
}
答
  • 程序一输出结果为:true
  • 程序二输出结果为:false
  • 程序三输出结果为:true
  • 程序四输出结果为:false

首先,程序一输出结果为 true 肯定没什么好解释的,本题考察的重点在于对后面程序输出结果的分析。

Integer 是 int 的装箱类型,它修饰的是一个对象。当我们使用 Integer a = xxx; 的方式声明一个变量时,Java 实际上会调用 Integer.valueOf() 方法。

我们来看下 Integer.valueOf() 的源代码:

1
2
3
4
5
java复制代码public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

JDK 文档中的说明:

1
2
sql复制代码* This method will always cache values in the range -128 to 127,
* inclusive, and may cache other values outside of this range.

也就是说,由于 -128 ~ 127 这个区间的值使用频率非常高,Java 为了减少申请内存的开销,将这些对象存储在 IntegerCache 中。

所以,如果使用 Integer 声明的值在 -128 ~ 127 这个区间内的话,就会直接从常量池中取出并返回,于是我们看到,程序二输出的结果为 false,因为 Integer = 1000;Integer b = 1000; a 和 b 的值并不是从常量池中取出的,它们指向的是堆中两块不同的内存区域。而程序三:Integer a = 1;Integer b = 1; 中的 a 和 b 指向的都是常量池中同一块内存,所以结果返回 true。

对于程序四的输出结果,我们需要知道,当 new 一个对象时,则一定会在堆中开辟一块新的内存保存对象,所以 a 和 b 指向的是不同的内存区域,结果自然返回 false~

总结

还是一道老掉牙的题目,不过一些走排场的笔试题中还是有出现过的。

8、说一下 Error 和 Exception 的区别?


答

先上图:

首先,Error类和Exception类都继承自Throwable类。

先谈一下 Error 吧~

Error表示系统级的错误,一般是指与虚拟机相关的问题,由虚拟机生成并抛出,常见的虚拟机错误有:OutOfMemoryError,StackOverflowError 等等。

OutOfMemoryError,StackOverflowError 这两种错误是要求大家务必掌握的。

StackOverflowError,即栈溢出错误,一般无限制地递归调用会导致 StackOverflowError 的发生,所以,再一次提醒大家,在写递归函数的时候一定要写 base case,否则就会导致栈溢出错误的发生。

如程序:

1
2
3
4
5
6
7
8
9
java复制代码public class StackOverflowErrorTest {
public static void foo(){
System.out.println("StackOverflowError");
foo();
}
public static void main(String[] args) {
foo();
}
}

该程序会导致抛出 StackOverflowError。

OutOfMemoryError,即堆内存溢出错误,导致 OutOfMemoryError 可能有如下几点原因:

  1. JVM启动参数内存值设定过小
  2. 代码中存在死循环导致产生过多对象实体
  3. 内存中加载的数据量过于庞大,一次从数据库取出过多的数据也会导致堆溢出
  4. 集合类中有对对象的引用,使用完后未清空,使得JVM无法回收

如程序:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class OutOfMemoryErrorTest {

public static void main(String[] args) {
while (true){
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) { }
}).start();
}
}
}

该程序为一个不断创建新线程的死循环,运行会抛出 OutOfMemoryError。

接下来,我们再来谈一下什么 Exception?(一本正经)

Exception表示异常,通俗地讲,它表示如果程序运行正常,则不会发生的情况。

Exception可以划分为

  • 运行时异常(RuntimeException)
  • 非运行时异常

或者也可以划分为:

  • 受检查异常(CheckedException)
  • 不受检查异常(UncheckedException)

实际上,运行时异常就是不受检查异常。

什么是运行时异常(RuntimeException),或者说什么是不受检查异常(UncheckedException)呢?

通俗地讲,不受检查异常是指程序员没有细心检查代码,造成例如:空指针,数组越界等情况导致的异常。这些异常通常在编码过程中是能够避免的。并且,我可以在代码中直接抛出一个运行时异常,程序编译不会出错,譬如这段代码:

1
2
3
4
5
java复制代码public class Test {
public static void main(String[] args) {
throw new IllegalArgumentException("wrong");
}
}

什么是受检查异常呢?

受检查异常是指在编译时被强制检查的异常。受检查异常要么使用try-catch语句进行捕获,要么使用throws向上抛出,否则是无法通过编译的。常见的受检查异常有:FileNotFoundException,SQLException等等。

总结

细心的小伙伴一定会发现,我的解答中实际上涵盖了很多的考点,面试官可以采用多种问法来考察你对 Java 异常体系的了解程度。譬如:哪些情况会发生 OutOfMemoryError?什么是运行时异常,什么是受检查异常?请列举一些常见的运行时异常和受检查异常?等等……

在这道题目的回答中,我已经将上述问题的答案都写进去了,慢慢寻找吧~

9、当代码执行到 try 块时,finally 块一定会被执行么?


答

不一定。

有两种情况会导致即便代码执行到 try 块,finally 块也有可能不执行:

  1. 系统终止
  2. 守护线程被终止

示例程序一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码package com.github.test;

public class Test {

public static void main(String[] args) {
foo();
}

public static void foo() {
try {
System.out.println("In try block...");
System.exit(0);
} finally {
System.out.println("In finally block...");
}
}
}

该程序运行的结果为:

1
erlang复制代码In try block...

原因在于,try 块中,我们使用了 System.exit(0) 方法,该方法会终止当前正在运行的虚拟机,也就是终止了系统,既然系统被终止,自然而然也就执行不到 finally 块的代码了。

示例程序二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码package com.github.test;

public class Test {

public static void main(String[] args) {
Thread thread = new Thread(new Task());
thread.setDaemon(true);
thread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}

class Task implements Runnable {

@Override
public void run() {
try {
System.out.println("In try block...");
Thread.sleep(5000); // 阻塞线程 5 s
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("In finally block");
}
}
}

该程序的输出结果为:

1
erlang复制代码In try block...

Java 的线程可以分为两大类:

  • Daemon Thread(守护线程)
  • User Thread(用户线程)

所谓的守护线程就是指程序运行的时候,再后台提供一种通用服务的线程,比如垃圾回收线程就是一个守护线程。守护线程并不属于程序中不可或缺的部分,因此,当所有的用户线程结束,程序也就终止,程序终止的同时也会杀死进程中所有的守护线程。

上面的实例程序中,main 执行完毕,程序就终止了,所以守护线程也就被杀死,finally 块的代码也就无法执行到了。

总结

现在 finally 使用的很少了,关闭资源都会选择 try with resources。不过这道题仍然是一个比较经典的题目~

10、谈一谈你对 Java 异常处理的心得?


答

本题是一道开放性问答题,答案并不唯一。面试官旨在考察面试者对 Java 异常的理解,本回答为我个人对异常处理的心得体会,并非标准答案,如果大家有更好的回答,可以评论提醒我进行查漏补缺。

原则一:使用 try-with-resources 来关闭资源

《Effective Java》中给出的一条最佳实践是:Prefer try-with-resources to try-finally 。

我们知道,Java 类库中包含许多必须通过调用 close 方法手动关闭资源的类,比如:InputStream,OutputStream,java.sql.Connection 等等。在 JDK 1.7 以前,try-finally 语句是保证资源正确关闭的最佳实践。

不过,try-finally 带来的最大问题有两点:

  1. 有一些资源需要保证按顺序关闭
  2. 当我们的代码中引入了很多需要关闭的资源时,代码就会变得冗长难以维护

从 JDK 1.7 开始,便引入了 try-with-resources,这些问题一下子都得到了解决。使用 try-with-resouces 这个构造的前提是,资源必须实现了 AutoCloseable 接口。Java 类库和第三方类库中的许多类和接口现在都实现或继承了 AutoCloseable 接口。

所以,我们应该使用 try-with-resources 代替 try-finally 来关闭资源。

原则二:如果你需要使用到 finally,那么请避免在 finally 块中使用 return 语句

我们来看两个示例程序

程序一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码package com.github.test;

public class Test {

public static int test() {
int i = 1;
try {
Integer.valueOf("abc");
} catch (NumberFormatException e) {
i++;
return i;
} finally {
i++;
return i;
}
}

public static void main(String[] args) {
System.out.println(test());
}
}

程序二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码package com.github.test;

public class Test {

public static int test() {
int i = 1;
try {
Integer.valueOf("abc");
i++;
} catch (NumberFormatException e) {
i++;
return i;
} finally {
i++;
}
return i;
}

public static void main(String[] args) {
System.out.println(test());
}
}

程序一的输出结果为:

1
复制代码3

程序二的输出结果为:

1
复制代码2

导致两个程序输出不同结果的原因在于:程序一,我们将 return 语句写在了 finally 块中;而程序二则是将 return 语句写在了代码的最后部分。

在 finally 块中写 return 语句是一种非常不好的实践,因为程序会将 try-catch 块里面的语句,或者是抛出的异常全部丢弃掉。如上面的代码,Integer.valueOf("abc"); 会抛出一个 NumberFormatException ,该异常被 catch 捕获处理,我们的本意是,在 catch 块中将异常处理并返回,但是由于示例一 finally 块中有 return 语句,导致 catch 块的返回值被丢弃。

我们需要铭记一点,如果 finally 代码块中有 return 语句,那么程序会优先返回 finally 块中 return 的结果。

为了避免这样的事情发生,我们应该避免在 finally 块中使用 return 语句。

原则三:Throw early,Catch late

关于异常处理,有一个非常著名的原则叫做:Throw early,Catch late。

原文链接🔗

微信公众号防链接丢失:

howtodoinjava.com/best-practi…

Remember “Throw early catch late” principle. This is probably the most famous principle about Exception handling. It basically says that you should throw an exception as soon as you can, and catch it late as much as possible. You should wait until you have all the information to handle it properly.
This principle implicitly says that you will be more likely to throw it in the low-level methods, where you will be checking if single values are null or not appropriate. And you will be making the exception climb the stack trace for quite several levels until you reach a sufficient level of abstraction to be able to handle the problem.

上文的含义是,遇到异常,你应该尽早地抛出,并且尽可能晚地捕获它。如果当前方法会抛出一个异常,我们应该判断,该异常是否应该交给这个方法处理,如果不是,那么最好的选择是将这个异常向上抛出,交给更高的调用级去处理它。

这样做的好处是,我们可以打印出更多的异常栈轨迹(Stacktrace),从最顶层的逻辑开始逐步向下,清楚地看到方法调用关系,以便我们理清报错原因。

原则四:捕获具体的异常,而不是它的父类

如果某个被调用的模块抛出了多个异常,那么只捕获这些异常的父类是非常不好的实践。

例如,某一个模块抛出了 FileNotFoundException 和 IOException ,那么调用这个模块的代码最好使用 catch 语句的级联分别捕获这两个异常,而不是只写一个 Exception 的 catch 块。

1
2
3
4
5
6
7
java复制代码try {
// ...
}catch(FileNotFoundException e) {
// handle
}catch(IOException e) {
// handle
}
总结

这个问题是一个非常好的问题,我们其实可以发挥更多的空间,譬如谈一下如何避免 OOM ——当我们的内存中加载的数据量过于庞大,一次从数据库取出过多的数据或者是读取一个非常大的文件时很容易导致 OOM,所以我们可以使用 Buffer 来缓冲避免一次读取太多的数据,从而达到避免 OOM 的发生……

总结

今天我主要分享了 Java 基础部分的一些常考题和知识点,虽然只有十道题,但是也涵盖了非常多的知识点,希望看到这篇文章的你能受益良多。

后续的内容我会尽快更新,不过为了保证内容的质量,也可能没那么快

好啦,至此为止,这篇文章就到这里了~欢迎大家关注我的公众号,在这里希望你可以收获更多的知识,我们下一期再见!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【得物技术】深入理解synchronzied底层原理

发表于 2021-09-29

一、synchronized简介

synchronized是Java中的关键字,是一种同步锁。在多线程编程中,有可能会出现多个线程同时争抢同一个共享资源的情况,这个资源一般被称为临界资源。这种共享资源可以被多个线程同时访问,且又可以同时被多个线程修改,然而线程的执行是需要CPU的资源调度,其过程是不可控的,所以需要采用一种同步机制来控制对共享资源的访问,于是线程同步锁——synchronized就应运而生了。

二、如何解决线程并发安全问题

多线程并发读写访问临界资源的情况下,是会存在线程安全问题的,可以采用的同步互斥访问的方式,就是在同一时刻,只能有同一个线程能够访问到临界资源。当多个线程执行同一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量会在类加载的时候存在每个线程的私有栈的局部变量表中,因此不属于共享资源,所有不会导致线程安全问题。

三、synchronized用法

synchronized关键字最主要有以下3种使用方式:

  1. 修饰类方法,作用于当前类加锁,如果多个线程不同对象访问该方法,则无法保证同步。
  2. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,锁的是包含这个方法的类,也就是类对象,这样如果多个线程不同对象访问该静态方法,也是可以保证同步的。
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

四、Synchronized原理分析

可以先通过一个简单的案例看一下同步代码块:

1
2
3
4
5
6
7
8
9
csharp复制代码public class SynchTestDemo {

public void print() {
synchronized ("得物") {
System.out.println("Hello World");
}
}

}

synchronized属于Java关键字,没办法直接看到其底层源码,所以只能通过class文件进行反汇编。

先通过javac SynchTestDemo.java指令直接SynchTestDemo.java文件编译成SynchTestDemo.class文件;再通过javap -v SynchTestDemo.class指令再对SynchTestDemo.class文件进行反汇编,可以得到下面的字节码指令:

这些反编译的字节码指令这里就不详细解释了,对照着JVM指令手册也能看懂是什么意思。通过上图反编译的结果可以看出,monitorexit指令实际上是执行了两次,第一次是正常情况下释放锁,第二次为发生异常情况时释放锁,这样做的目的在于保证线程不死锁。

monitorenter

首先可以看一下JVM规范中对于monitorenter的描述:

翻译过来就是:任何一个对象都有一个monitor与其相关联,当且有一个monitor被持有后,它将处于锁定的状态,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,他会尝试去获取当前对应的monitor的所有权。其过程如下:

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

monitorexit

也可以先看一下JVM规范中对monitorexit的描述:

翻译过来就是:

  1. 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程;
  2. 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权;

synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置,如下图所示:

每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:

通过上面的描述可以看出synchronized的实现原理:synchronized的底层实际是通过一个monitor对象来实现的,其实wait/notify方法也是依赖于monitor对象来实现的,这就是为什么只有在同步代码块或者方法中才能调用该方法,否则就会抛出出java.lang.IllegalMonitorStateException的异常的原因。

下面可以再通过一个简单的案例看一下同步方法:

1
2
3
4
5
6
7
csharp复制代码public class SynchTestDemo {

public synchronized void print() {
System.out.println("Hello World");
}

}

与上面同理可以查看到,该方法的字节码指令:

从字节码反编译的可以看出,同步方法并没有通过指令monitorenter和monitorexit来实现的,但是相对于普通方法来说,其常量池多了了 ACC_SYNCHRONIZED 标示符。JVM实际就是根据该标识符来实现方法的同步的。

当方法被调用时,会检查ACC_SYNCHRONIZED标志是否被设置,若被设置,线程会先获取monitor,获取成功才能执行方法体,方法执行完成后会再次释放monitor。在方法执行期间,其他线程都无法获得同一个monitor对象。

其实两种同步方式从本质上看是没有区别的,两个指令的执行都是JVM调用操作系统的互斥原语mutex来实现的,被阻塞的线程会被挂起、等待重新调度,会导致线程在“用户态”和“内核态”进行切换,就会对性能有很大的影响。

五、什么是monitor?

monitor通常被描述为一个对象,可以将其理解为一个同步工具,或者可以理解为一种同步机制。所有的Java对象自打new出来的时候就自带了一把锁,就是monitor锁,也就是对象锁,存在于对象头(Mark Word),锁标识位为10,指针指向的是monitor对象起始地址。在Java虚拟机(HotSpot)中,Monitor是由其底层实际是由C++对象ObjectMonitor实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码ObjectMonitor() {
_header = NULL;
_count = 0; //用来记录该线程获取锁的次数
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL; // 存储该monitor的对象
_owner = NULL; // 标识拥有该monitor的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL; // 多线程竞争锁时的单向队列
FreeNext = NULL;
_EntryList = NULL; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
}
  1. _owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的;
  2. _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。cxq是一个临界资源,JVM通过CAS原子指令来修改cxq队列。修改前cxq的旧值填入了node的next字段,_cxq指向新值(新线程)。因此_cxq是一个后进先出的stack(栈);
  3. _EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中;
  4. _WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中。

举个例子具体分析一下_cxq队列与_EntryList队列的区别:

1
2
3
4
5
6
csharp复制代码public void print() throws InterruptedException {
synchronized (obj) {
System.out.println("Hello World");
//obj.wait();
}
}

若多线程执行上面这段代码,刚开始t1线程第一次进同步代码块,能够获得锁,之后马上又有一个t2线程也准备执行这段代码,t2线程是没有抢到锁的,t2这个线程就会进入_cxq这个队列进行等待,此时又有一个线程t3准备执行这段代码,t3当然也会没有抢到这个锁,那么t3也就会进入_cxq进行等待。接着,t1线程执行完同步代码块把锁释放了,这个时候锁是有可能被t1、t2、t3中的任何一个线程抢到的。假如此时又被t1线程给抢到了,那么上次已经进入_cxq这个队列进行等待的线程t2、t3就会进入_EntryList进行等待,若此时来了个t4线程,t4线程没有抢到锁资源后,还是会先进入_cxq进行等待。

下面具体分析一下_WaitSet队列与_EntryList队列:

每个object的对象里 markOop->monitor() 里可以保存ObjectMonitor的对象。ObjectWaiter 对象里存放thread(线程对象) 和unpark的线程, 每一个等待锁的线程都会有一个ObjectWaiter对象,而objectwaiter是个双向链表结构的对象。

结合上图monitor的结构图可以分析出,当线程的拥有者执行完线程后,会释放锁,此时有可能是阻塞状态的线程去抢到锁,也有可能是处于等待状态的线程被唤醒抢到了锁。在JVM中每个等待锁的线程都会被封装成ObjectMonitor对象,_owner标识拥有该monitor的线程,而_EntryList和_WaitSet就是用来保存ObjectWaiter对象列表的,_EntryList和_WaitSet最大的区别在于前者是用来存放等待锁block状态的线程,后者是用来存放处于wait状态的线程。

当多个线程同时访问同一段代码时:

  • 首先会进入_EntryList集合每当线程获取到对象的monitor后,会将monitor中的_ower变成设置为当前线程,同时会将monitor中的计数器_count加1
  • 若线程调用wait()方法时,将释放当前持有的monitor对象,将_ower设置为null,_count减1,同时该线程进入_WaitSet中等待被唤醒
  • 若当前线程执行完毕,也将释放monitor锁,并将_count值复原,以便于其他线程获取锁

monitor对象存在于每个Java对象的对象头(Mark Word)中,所以Java中任何对象都可以作为锁,由于notify/notifyAll/wait等方法会使用到monitor锁对象,所以必须在同步代码块中使用。多线程情况下,线程需要同时访问临界资源,监视器monitor可以确保共享数据在同一时刻只会有一个线程在访问。

那么问题来了,synchronized是对象锁,加锁就是加在对象上,那对象时如何记录锁的状态的呢?答案就是锁的状态是记录在每个对象的对象头(Mark Word)中的,那什么是对象头呢?

六、什么是对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:

对象头又包括两部分信息,第一部分用于存储对象自身的运行时数据(Mark Word),如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针(Klass pointer),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Class<? extends SynchTestDemo> synchClass = synchTestDemo.getClass();

值得注意的是:类元信息存在于方法区,类元信息有区别与堆中的synchClass字节码对象,synchClass可以理解为类加载完成后,JVM将类的信息存在堆中,然后使用反射去访问其全部信息(包括函数和字段),然而在JVM内部大多数对象都是使用C++代码实现的,对于JVM内部如果需要类信息,JVM就会通过对象头的类型指针去拿方法区中类元信息的数据。

实例数据:存放类的属性数据信息,包括父类的属性信息。

对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

下面可以看一下对像头的结构:

在32位虚拟机下,Mark Word是32bit大小的,其存储结构如下:

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

现在虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。也可以通过下面参数进行控制JVM开启和关闭指针压缩:

开启压缩指针(-XX:+UseCompressedOops) 关闭压缩指针(-XX:-UseCompressedOops)

那为什么JVM需要默认开启指针压缩呢?原因在于在对象头上类元信息指针Klass pointer在32位JVM虚拟机中用4个字节存储,但是到了64位JVM虚拟机中Klass pointer用的就是8个字节来存储,一些对象在32位虚拟机用的也是4字节来存储,到了64位机器用的都是8字节来存储了,一个工程项目中有成千上万的对象,倘若每个对象都用8字节来存放的话,那这些对象无形中就会增加很多空间,导致堆的压力就会很大,堆很容易就会满了,然后就会更容易的触发GC,那指针压缩的最主要的作用就是压缩每个对象内存地址的大小,那么同样堆内存大小就可以放更多的对象。

这里刚好可以再说一个额外的小知识点:对象头中有4个字节用于存放对象分代年龄的,4个字节就是2的四次方等于16,其范围就是0~15,所以也就很好理解对象在GC的时候,JVM对象由年轻代进入老年代的默认分代年龄是15了。

七、synchronized锁的优化

操作系统分为“用户空间”和“内核空间”,JVM是运行在“用户态”的,jdk1.6之前,在使用synchronized锁时需要调用底层的操作系统实现,其底层monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从“用户态”转为“内核态”,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能 带来了很大的压力。同这个时候CPU就需要从“用户态”切向“内核态”,在这个过程中就非常损耗性能而且效率非常低,所以说jdk1.6之前的synchronized是重量级锁。如下图所示:

然后有位纽约州立大学的教授叫Doug Lea看到jdk自带的synchronized性能比较低,于是他利用纯Java语言实现了基于AQS的ReentrantLock锁(底层当然也调用了底层的语言),如下图所示,可以说ReentrantLock锁的出现完全是为了弥补synchronized锁的各种不足。

由于synchronized锁性能严重不足,所以oracle官方在jdk1.6之后对synchronized锁进行了升级,如上图所示的锁升级的整个过程。所以就有了以下的这些名词:

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其底层是通过CAS实现的。无锁无法全方位代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁(无锁 -> 偏向锁)

偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及 ThreadID即可。

一开始无锁状态,JVM会默认开启“匿名”偏向的一个状态,就是一开始线程还未持有锁的时候,就预先设置一个匿名偏向锁,等一个线程持有锁之后,就会利用CAS操作将线程ID设置到对象的mark word 的高23位上【32位虚拟机】,下次线程若再次争抢锁资源的时,多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

轻量级锁(偏向锁 -> 轻量锁)

当线程交替执行同步代码块时,且竞争不激烈的情况下,偏向锁就会升级为轻量级锁。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

自旋锁

在很多场景下,共享资源的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这就是自旋锁。

当一个线程t1、t2同事争抢同一把锁时,假如t1线程先抢到锁,锁不会立马升级成重量级锁,此时t2线程会自旋几次(默认自旋次数是10次,可以使用参数-XX : PreBlockSpin来更改),若t2自旋超过了最大自旋次数,那么t2就会当使用传统的方式去挂起线程了,锁也升级为重量级锁了。

自旋的等待不能代替阻塞,暂且不说对处理器数量的要求必须要两个核,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等 待的效果就会非常好,如果锁被占用的时间很长,那自旋的线程只会消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

自旋锁在jdk1.4中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在jdk1.6之后自旋锁就已经默认是打开状态了。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

锁消除

锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码public class SynchRemoveDemo {
public static void main(String[] args) {
stringContact("AA", "BB", "CC");
}
public static String stringContact(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
return sb.append(s1).append(s2).append(s3).toString();
}
}

//append()方法源码
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}

StringBuffer的append()是一个同步方法,锁就是this也就是sb对象。虚拟机发现它的动态作用域被限制在stringContact()方法内部。也就是说, sb对象的引用永远不会“逃逸”到stringContact()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

这里顺便说一个小的JVM知识点——“对象的逃逸分析”:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象优先在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。上面sb对象的就是不会逃逸出方法stringContact(),所以sb对象有可能优先分配在线程栈中,只是有可能哟,这里点到为止,需要了解可以自行学习哟~

锁粗化

JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。可以通过下面的例子来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码public class SynchDemo {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 50; i++) {
sb.append("AA");
}
System.out.println(sb.toString());
}
}

//append()方法源码
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}

StringBuffer的append()是一个同步方法,通过上面的代码可以看出,每次循环都要给append()方法加锁,这时系统会通过判断将其修改为下面这种,直接将原append()方法的synchronized的锁给去掉直接加在了for循环外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typescript复制代码public class SynchDemo {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
synchronized(sb){
for (int i = 0; i < 50; i++) {
sb.append("AA");
}
}
System.out.println(sb.toString());
}
}

//append()方法源码
@Override
public StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}

八、通过对象头分析锁升级过程

可以通过对象头分析工具观察一下锁升级时对象头的变化:运行时对象头锁状态分析工具JOL,是OpenJDK开源工具包,引入下方maven依赖

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol‐core</artifactId>
<version>0.10</version>
</dependency>

观察无锁状态下的对象头【无锁状态】:

1
2
3
4
typescript复制代码 public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}

运行结果:

1
2
3
4
5
6
7
8
python复制代码java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 第一行:对象头MarkWord
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 第二行:对象头MarkWord
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 第三行:klass Pointer
12 4 (loss due to the next object alignment) 第四行:对齐填充
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

这里先详细解释一下打印结果,后面就不做详细分析了:

OFFSET : 内存地址偏移量

SIZE : 本条信息对应的字节大小

Instance size: 16 bytes :本次new出的Object对象的大小

由于当前所使用的的机器是64位操作系统的机器,所以前两行代表的就是对象头MarkWord,已经在上述运行结果中标出,刚好是8字节,每个字节8位,刚好是64位;由上文中32位对象头与64位对象头的位数对比可知,分析对象头锁升级情况看第一行的对象头即可。

第三行指的是类型指针(上文中有说过,指向的是方法区的类元信息),已经在上述运行结果中标出,Klass Pointer在64位机器默认是8字节,这里由于指针压缩的原因当前是4字节。

第四行指的是对齐填充,有的时候有有的时候没有,JVM内部需要保证对象大小是8个字节的整数倍,实际上计算机底层通过大量计算得出对象时8字节的整数倍可以提高对象存储的效率。

可以观察到本次new出的Object对象的大小实际只有12字节,这里对象填充为其填充了4个字节,就是为了让Object对象大小为16字节是8字节的整数倍。

JVM采用的是小端模式,需要现将其转换成大端模式,具体转换如下图所示:

可以看出一开始对象没有加锁,通过最后三位的“001”也能观察到,前25位代表hashcode,那这里为什么前25位是0呢?其实hashcode是通过C语言类似于“懒加载”的方式获取到的,所以看到该对象的高25位并没有hashcode。

观察有锁无竞争状态下的对象头【无锁->偏向锁】:

1
2
3
4
5
6
7
csharp复制代码 public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
synchronized (object){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

运行结果(JVM默认小端模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python复制代码java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 90 39 62 05 (10010000 00111001 01100010 00000101) (90323344)
4 4 (object header) 00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

运行结果分析:

通过运行结果可以看到,先打印出来的是一个“001”无锁的状态,但是后打印出来的“000”并不是偏向锁的状态,查上面的表可以发现“000”直接就是轻量级锁的状态了。JVM启动的时候内部实际上也是有很多个线程在执行synchronized,JVM就是为了避免无畏的锁升级过程(偏向锁->轻量级锁->重量级锁)带来的性能开销,所以JVM默认状态下会延迟启动偏向锁。只要将代码前面加个延迟时间即可观察到偏向锁:

1
2
3
4
5
6
7
8
scss复制代码public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(6);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

运行结果(JVM默认小端模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python复制代码java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 90 80 de (00000101 10010000 10000000 11011110) (-561999867)
4 4 (object header) b2 7f 00 00 (10110010 01111111 00000000 00000000) (32690)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

对未开启偏向锁与开启偏向锁的运行结果分析:

1
2
3
复制代码未开启偏向锁(大端模式),没加锁:00000000 00000000 00000000 00000001
开启偏向锁(大端模式),没加锁 :00000000 00000000 00000000 00000101
开启偏向锁(大端模式),加锁 :11011110 10000000 10010000 00000101

开启偏向锁之后的无锁状态,会加上一个偏向锁,叫匿名偏向(可偏向状态),表示该对象锁是可以加偏向锁的,从高23位的23个0可以看出暂时还没有偏向任何一个线程,代表已经做好了偏向的准备,就等着接下来的某个线程能拿到就直接利用CAS操作把线程id记录在高23位的位置。

观察有锁有竞争状态下的对象头【偏向锁->轻量级锁】:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
scss复制代码public static void main(String[] args) throws InterruptedException {

Thread.sleep(5000);

Object object = new Object();

//main线程
System.out.println(ClassLayout.parseInstance(object).toPrintable());

//线程t1
new Thread(() -> {
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
},"t1").start();

Thread.sleep(2000);

//main线程
System.out.println(ClassLayout.parseInstance(object).toPrintable());
//线程t2
new Thread(() -> {
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
},"t2").start();
}

运行结果(JVM默认小端模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
kotlin复制代码java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5) //main线程打印
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 90 94 2d (00000101 10010000 10010100 00101101) (764710917) //t1线程打印
4 4 (object header) c9 7f 00 00 (11001001 01111111 00000000 00000000) (32713)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 90 94 2d (00000101 10010000 10010100 00101101) (764710917) //main线程打印
4 4 (object header) c9 7f 00 00 (11001001 01111111 00000000 00000000) (32713)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 08 a9 d5 07 (00001000 10101001 11010101 00000111) (131442952) //t2线程打印
4 4 (object header) 00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

运行结果分析:

一开始main线程打印出的object对象头可以看出是匿名偏向;

接着线程t1打印了object对象头,可以与第一个打印出来的对象头对比不难发现t1打印的也是偏向锁,但是t1打印的对象头已经把t1的线程id记录在了其对应的23位;

程序再次回到main线程,其还是打印出来刚刚t1的对象头数据,也就是说偏向锁一旦偏向了某个线程后,如果线程不能重新偏向的话,那么这个偏向锁还是会一直记录着之前偏向的那个线程的对象头状态;

接着线程t2又开始打印了object对象头,可以看出最后一次打印已经升级成了轻量级锁,因为这里已经存在两个线程t1、t2交替进入了object对象锁的同步代码块,并且锁的不激烈竞争,所以锁已经升级成了轻量级锁。

观察无锁升级成重量级锁状态下的对象头的整个过程【无锁->重量级锁】:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
scss复制代码public static void main(String[] args) throws InterruptedException {
sleep(5000);
Object object = new Object();

System.out.println(ClassLayout.parseInstance(object).toPrintable());

new Thread(()->{
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
//延长锁的释放,造成锁的竞争
try {
sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t0").start();

sleep(5000);

new Thread(() -> {
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
//延长锁的释放,造成锁的竞争
try {
sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t1").start();

new Thread(() -> {
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
try {
sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t2").start();

}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
kotlin复制代码java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5) //main线程打印
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 d8 8f ef (00000101 11011000 10001111 11101111) (-275785723) //t0线程打印
4 4 (object header) ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 00 e9 a9 09 (00000000 11101001 10101001 00001001) (162130176) //t1线程打印
4 4 (object header) ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 0a d8 80 f0 (00001010 11011000 10000000 11110000) (-259991542) //t2线程打印
4 4 (object header) ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

运行结果分析(JVM默认小端模式):

程序一开始就是设置了5秒钟的睡眠,目的在于让JVM优先加载完成后,让JVM默认状态下会延迟启动偏向锁,可以开出一开始main线程打印的是“101”就是默认的匿名偏向锁,但是并没有设置线程id;之后t0线程就立马打印了,此时只需利用CAS操作把t0的线程id设置进对象头即可,所以这个时候也是一个偏向锁状态;之后的程序睡眠5秒钟后,程序中t1、t2线程执行代码块时,有意的将其线程睡眠几秒钟,目的在于不管那个线程率先抢到锁,都能让另外一个线程在自旋等待中,所以t1线程打印的是“00”就已经是轻量级锁了,最后看程序执行结果,t2打印的是“10”就已经升级为重量级锁了,显然t2线程已经超过了自旋的最大次数,已经转成重量级锁了。

九、总结

那平时写代码如何对synchronized优化呢?

我总结就是:

1、减少synchronized的范围,同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。

2、降低synchronized锁的粒度,将一个锁拆分为多个锁提高并发度。这点其实可以参考HashTable与ConcurrentHashMap的底层原理。

HashTable加锁实际上锁的是整个hash表,一个操作进行的时候,其他操作都无法进行了。

然而ConcurrentHashMap是局部锁定,锁得并不是一整张表,ConcurrentHashMap锁得是一个segment,当前的segment被锁了,不影响其他segment的操作。

文/harmony

关注得物技术,做最潮技术人!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java开发工具,IntelliJ IDEA的安装设置教程(

发表于 2021-09-29
  • 小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

6. 注册 IntelliJ IDEA

如果要尝试评估 IntelliJ IDEA,您可以免费下载并安装其试用版。试用版可用30天,您需要获得并注册许可证。

IntelliJ IDEA 构建,可以作为早期访问计划的一部分下载,不需要任何注册,并附带30天许可证。

⑴ 执行以下操作之一:

在欢迎屏幕上,单击配置| 管理许可证

选择帮助| 从主菜单注册

image.png

⑵ 选择你想要注册的 IntelliJ IDEA:

JetBrains 帐户:如果你有 JetBrains 帐户允许您访问您的购买和管理许可证,请选择此选项。

激活代码:如果您具有IntelliJ IDEA的激活码,并将其粘贴到文本区域,请选择此选项。

许可证服务器:选择此选项通过许可证服务器Web应用程序注册 IntelliJ IDEA,允许您管理浮动许可证并向没有直接 Internet 访问权限的用户颁发许可证。

7. 更新 IntelliJ IDEA

⑴ 下载 IntelliJ IDEA的最新版本。

⑵ 按照 “下载并安装IntelliJ IDEA” 中的说明启动设置

⑶ 选择是否要卸载现有的产品版本及其设置,缓存和本地历史记录,并按照安装向导的指示进行操作。

8. 通过 Toolbox 应用程序管理 IntelliJ IDEA

Toolbox App 是一个控制面板,允许您从单一访问点管理所有 JetBrains 开发人员工具,包括 IntelliJ IDEA 以及项目。它允许您维护同一工具的不同版本,安装更新并在需要时将其回滚。 它还记住您的 JetBrains 帐户,并在安装和注册新工具时使用它自动登录。

⑴ 下载 工具箱的应用

⑵ 启动安装文件。

⑶ 当安装完成时,接受 JetBrains 隐私政策和登录您的 JetBrains 账户。

现在你可以管理现有工具,安装新的工具和下载更新:

image.png

9. 在Windows上执行静默安装

无需任何用户界面即可执行静默安装。网络管理员可以使用它在许多机器上安装IntelliJ IDEA,避免中断其他用户。

要执行静默安装,请使用以下开关运行安装程序:

/S:启用静默安装

/D:指定安装目录的路径

/CONFIG:指定静默配置文件的路径

例如:

ideaIU.exe /S /CONFIG=d:\temp\silent.config /D=d:\IDE\IntelliJ IDEA Ultimat

10. 静默配置文件

您可以在download.jetbrains.com/idea/silent… IDEA的静默配置文件。

静默配置文件定义了安装IntelliJ IDEA的选项。使用默认选项,仅对当前用户执行静默安装(mode=user)。如果要为所有用户安装IntelliJ IDEA,请使用文本编辑器打开静默配置文件,更改安装模式选项(mode=admin)的值并以管理员身份运行安装程序。

默认的静默配置文件对于每个JetBrains产品都是唯一的。您可以根据需要对其进行修改以启用或禁用各种安装选项。

11. 静默卸载IntelliJ IDEA

要以静默方式卸载IntelliJ IDEA,请以管理员身份使用/S开关运行卸载程序。卸载程序位于bin下的安装目录中。

以管理员身份运行cmd(Windows命令提示符),切换到IntelliJ IDEA安装目录,然后运行以下命令:

bin\uninstall.exe /S

12. 在Linux上将IntelliJ IDEA安装为快照包

您可以在Linux上安装IntelliJ IDEA作为独立的快照包。由于快照自动更新,因此IntelliJ IDEA安装始终是最新的。

要使用快照,请按照安装指南中的说明在您的计算机上安装并运行该snapd服务。

在Ubuntu 16.04 LTS及更高版本上,此服务已预先安装。

IntelliJ IDEA通过两个渠道分发:

  1. stable通道仅包括稳定版本。要安装IntelliJ IDEA的最新稳定版本,请运行以下命令:

Ultimate Edition:$ sudo snap install intellij-idea-ultimate --classic

Community Edition:$ sudo snap install intellij-idea-community --classic

  1. edge通道包括EAP构建。要安装IntelliJ IDEA的最新EAP版本,请运行以下命令:

Ultimate Edition:$ sudo snap install intellij-idea-ultimate --classic --edge

Community Edition:$ sudo snap install intellij-idea-community --classic --edge

安装快照后,您可以通过运行 intellij-idea-community 或 intellij-idea-ultimate 命令启动它 。

要列出所有已安装的快照,请运行 sudo snap list。有关更多信息,请参阅Snapcraft文档。

该–classic选项是必需的,因为IntelliJ IDEA快照需要完全访问系统,就像传统打包的应用程序一样。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

浅谈Java锁机制 悲观锁和乐观锁 悲观锁应用 乐观锁应用

发表于 2021-09-29

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

在多线程环境下,程序往往会出现一些线程安全问题,为此,Java提供了一些线程的同步机制来解决安全问题,比如:synchronized锁和Lock锁都能解决线程安全问题。

悲观锁和乐观锁

我们可以将锁大体分为两类:

  • 悲观锁
  • 乐观锁

顾名思义,悲观锁总是假设最坏的情况,每次获取数据的时候都认为别的线程会修改,所以每次在拿数据的时候都会上锁,这样其它线程想要修改这个数据的时候都会被阻塞直到获取锁。比如MySQL数据库中的表锁、行锁、读锁、写锁等,Java中的synchronized和ReentrantLock等。

而乐观锁总是假设最好的情况,每次获取数据的时候都认为别的线程不会修改,所以并不会上锁,但是在修改数据的时候需要判断一下在此期间有没有别的线程修改过数据,如果没有修改过则正常修改,如果修改过则这次修改就是失败的。常见的乐观锁有版本号控制、CAS算法等。

悲观锁应用

案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class LockDemo {

static int count = 0;

public static void main(String[] args) throws InterruptedException {
List<Thread> threadList = new ArrayList<>();
for (int i = 0; i < 50; i++) {
Thread thread = new Thread(() -> {
for (int j = 0; j < 1000; ++j) {
count++;
}
});
thread.start();
threadList.add(thread);
}
// 等待所有线程执行完毕
for (Thread thread : threadList) {
thread.join();
}
System.out.println(count);
}
}

在该程序中一共开启了50个线程,并在线程中对共享变量count进行++操作,所以如果不发生线程安全问题,最终的结果应该是50000,但该程序中一定存在线程安全问题,运行结果为:

1
复制代码48634

若想解决线程安全问题,可以使用synchronized关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class LockDemo {

static int count = 0;

public static void main(String[] args) throws InterruptedException {
List<Thread> threadList = new ArrayList<>();
for (int i = 0; i < 50; i++) {
Thread thread = new Thread(() -> {
// 使用synchronized关键字解决线程安全问题
synchronized (LockDemo.class) {
for (int j = 0; j < 1000; ++j) {
count++;
}
}
});
thread.start();
threadList.add(thread);
}
for (Thread thread : threadList) {
thread.join();
}
System.out.println(count);
}
}

将修改count变量的操作使用synchronized关键字包裹起来,这样当某个线程在进行++操作时,别的线程是无法同时进行++的,只能等待前一个线程执行完1000次后才能继续执行,这样便能保证最终的结果为50000。

使用ReentrantLock也能够解决线程安全问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public class LockDemo {

static int count = 0;

public static void main(String[] args) throws InterruptedException {
List<Thread> threadList = new ArrayList<>();
Lock lock = new ReentrantLock();
for (int i = 0; i < 50; i++) {
Thread thread = new Thread(() -> {
// 使用ReentrantLock关键字解决线程安全问题
lock.lock();
try {
for (int j = 0; j < 1000; ++j) {
count++;
}
} finally {
lock.unlock();
}
});
thread.start();
threadList.add(thread);
}
for (Thread thread : threadList) {
thread.join();
}
System.out.println(count);
}
}

这两种锁机制都是悲观锁的具体实现,不管其它线程是否会同时修改,它都直接上锁,保证了原子操作。

乐观锁应用

由于线程的调度是极其耗费操作系统资源的,所以,我们应该尽量避免线程在不断阻塞和唤醒中切换,由此产生了乐观锁。

在数据库表中,我们往往会设置一个version字段,这就是乐观锁的体现,假设某个数据表的数据内容如下:

1
2
3
4
5
sql复制代码+----+------+----------+ ------- +
| id | name | password | version |
+----+------+----------+ ------- +
| 1 | zs | 123456 | 1 |
+----+------+----------+ ------- +

它是如何避免线程安全问题的呢?

假设此时有两个线程A、B想要修改这条数据,它们会执行如下的sql语句:

1
2
3
sql复制代码select version from e_user where name = 'zs';

update e_user set password = 'admin',version = version + 1 where name = 'zs' and version = 1;

首先两个线程均查询出zs用户的版本号为1,然后线程A先执行了更新操作,此时将用户的密码修改为了admin,并将版本号加1,接着线程B执行更新操作,此时版本号已经为2了,所以更新肯定是失败的,由此,线程B就失败了,它只能重新去获取版本号再进行更新,这就是乐观锁,我们并没有对程序和数据库进行任何的加锁操作,但它仍然能够保证线程安全。

CAS

仍然以最开始做加法的程序为例,在Java中,我们还可以采用一种特殊的方式来实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class LockDemo {

static AtomicInteger count = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException {
List<Thread> threadList = new ArrayList<>();
for (int i = 0; i < 50; i++) {
Thread thread = new Thread(() -> {
for (int j = 0; j < 1000; ++j) {
// 使用AtomicInteger解决线程安全问题
count.incrementAndGet();
}
});
thread.start();
threadList.add(thread);
}
for (Thread thread : threadList) {
thread.join();
}
System.out.println(count);
}
}

为何使用AtomicInteger类就能够解决线程安全问题呢?

我们来查看一下源码:

1
2
3
java复制代码public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

当count调用incrementAndGet()方法时,实际上调用的是UnSafe类的getAndAddInt()方法:

1
2
3
4
5
6
7
8
java复制代码public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

getAndAddInt()方法中有一个循环,关键的代码就在这里,我们假设线程A此时进入了该方法,此时var1即为AtomicInteger对象(初始值为0),var2的值为12(这是一个内存偏移量,我们可以不用关心),var4的值为1(准备对count进行加1操作)。

首先通过AtomicInteger对象和内存偏移量即可得到主存中的数据值:

1
java复制代码var5 = this.getIntVolatile(var1, var2);

获取到var5的值为0,然后程序会进行判断:

1
java复制代码!this.compareAndSwapInt(var1, var2, var5, var5 + var4)

compareAndSwapInt()是一个本地方法,它的作用是比较并交换,即:判断var1的值与主存中取出的var5的值是否相同,此时肯定是相同的,所以会将var5+var4的值赋值给var1,并返回true,对true取反为false,所以循环就结束了,最终方法返回1。

这是一切正常的运行流程,然而当发生并发时,处理情况就不太一样了,假设此时线程A执行到了getAndAddInt()方法:

1
2
3
4
5
6
7
8
java复制代码public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

线程A此时获取到var1的值为0(var1即为共享变量AtomicInteger),当线程A正准备执行下去时,线程B抢先执行了,线程B此时获取到var1的值为0,var5的值为0,比较成功,此时var1的值就变为1;这时候轮到线程A执行了,它获取var5的值为1,此时var1的值不等于var5的值,此次加1操作就会失败,并重新进入循环,此时var1的值已经发生了变化,此时重新获取var5的值也为1,比较成功,所以将var1的值加1变为2,若是在获取var5之前别的线程又修改了主存中var1的值,则本次操作又会失败,程序重新进入循环。

这就是利用自旋的方式来实现一个乐观锁,因为它没有加锁,所以省下了线程调度的资源,但也要避免程序一直自旋的情况发生。

手写一个自旋锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码public class LockDemo {

private AtomicReference<Thread> atomicReference = new AtomicReference<>();

public void lock() {
// 获取当前线程对象
Thread thread = Thread.currentThread();
// 自旋等待
while (!atomicReference.compareAndSet(null, thread)) {
}
}

public void unlock() {
// 获取当前线程对象
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread, null);
}

static int count = 0;

public static void main(String[] args) throws InterruptedException {
LockDemo lockDemo = new LockDemo();
List<Thread> threadList = new ArrayList<>();
for (int i = 0; i < 50; i++) {
Thread thread = new Thread(() -> {
lockDemo.lock();
for (int j = 0; j < 1000; j++) {
count++;
}
lockDemo.unlock();
});
thread.start();
threadList.add(thread);
}
// 等待线程执行完毕
for (Thread thread : threadList) {
thread.join();
}
System.out.println(count);
}
}

使用CAS的原理可以轻松地实现一个自旋锁,首先,AtomicReference中的初始值一定为null,所以第一个线程在调用lock()方法后会成功将当前线程的对象放入AtomicReference,此时若是别的线程调用lock()方法,会因为该线程对象与AtomicReference中的对象不同而陷入循环的等待中,直到第一个线程执行完++操作,调用了unlock()方法,该线程才会将AtomicReference值置为null,此时别的线程就可以跳出循环了。

通过CAS机制,我们能够在不添加锁的情况下模拟出加锁的效果,但它的缺点也是显而易见的:

  • 循环等待占用CPU资源
  • 只能保证一个变量的原子操作
  • 会产生ABA问题

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…512513514…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%