本文來自: PerfMa技術社群
概述
之所以會寫這個,主要是因為最近做的一個專案碰到了一個移位的問題,因為位移操作溢位導致結果不準確,本來可以點到為止,問題也能很快解決,但是不痛不癢的感覺著實讓人不爽,於是深扒了下箇中細節,直到看到Intel的指令規約才算釋然,希望這篇文章能引起大家共鳴。
本文或許看起來會比較枯燥,不過其實認真看挺有意思的,如果實在看不下去,告訴你一個極簡路徑,先看下下面的Demo,然後直接跳到後面的小結,如果懂了,別忘記順便點個贊,請叫我雷鋒,哈哈。
Demo
還是從一個簡單的例子說起
大家可以嘗試做幾個改變,看看結果怎樣
-
4 << shift
改成4L << shift
- 將35改成291,PS:提示一下
291=25+256*1
如果上面的各種結果你都能解釋,那說明你對位移操作還是有一定了解的,不過本文主要從JVM到Intel X86_64指令角度來分析這個問題,或許也值得一看
JVM裡4和4L的區別
要知道區別,我們看doShiftL
方法通過javac編譯出來的指令有什麼不一樣
4 << shift的位元組碼
0: iconst_4
1: iload_0
2: ishl
4L << shift的位元組碼
0: ldc2_w #34 // long 4l
3: iload_0
4: lshl
針對4和4L的區別,我們看到了兩條不同的指令,分別是iconst_4
和ldc2_w
,其實如果我們將4改成其他的值,可能會有不一樣的指令出現
-1<= x <=5: iconst_x
-128<= x <-1 || 5< x <=127:bipush
-32768 <= x < -128 || 127 < x <= 32767:sipush
-32768 > x || x > 32767:ldc
不過這些都不是我們今天的重點,不想細說了,就以iconst_4為例來簡單介紹下
iconst_4
先看iconst_4的大概彙編指令如下
重點看0x00007fcb529b0b30這條就是將0x4移到EAX暫存器裡,這是一個32位的暫存器,需要注意的是這裡並沒有直接將4 push到運算元棧上,而是在下一條指令(也就是iload_0)執行的時候才預先push到棧上,後面看iload_0的彙編程式碼可知
ldc2_w
ldc2_w是將long或者double的常量值從常量池推到運算元棧頂,其大概彙編指令如下
重點看0x00007fcb529b1990
這條開始,主要就是從常量池裡取出相關的值,然後push到運算元棧上(看0x00007fcb529b19c2
這行開始的接下來三行)
因此做一個小結:
-
iconst_4
:將4存入到EAX暫存器,但是此時還並沒有將4 push到運算元棧頂 -
ldc2_w
:將後面跟著的值(其實也就會4),存到RAX暫存器,並且將其push到運算元棧頂
著重注意下上面兩條指令使用的兩個暫存器是不一樣的,一個是EAX,一個是RAX,其中RAX是64位暫存器,而EAX是RAX暫存器的低32位,是一個32位暫存器
不過還沒結束,對於iconst_4
這種情況,什麼時候將4 push到棧上呢,那接下來我們看看iload_0
這條指令,因為不管是iconst_4
還是ldc2_w
,後面都跟了iload_0
,所以還是一起來看看這條指令
iload_0
iload_0
的彙編實現大致如下:
這條指令簡單來說就是將方法的0號local槽裡的資料存到EAX暫存器裡,不過針對上一條指令是iconst_4
,此時會先做一個push的動作,將RAX暫存器裡的值push到運算元棧上,但是如果是ldc2_w
指令的話,就不會做push了,因為這兩條指令規定的執行完後的top of stack不一樣,iconst_4
要求棧頂是一個int,而ldc2_w
沒要求,儘管在實現裡確實將值push到了棧頂
因此在執行完iload_0
之後,都已經將4 push到運算元棧頂了,並且將第一個local槽,其實就是doShiftL
函式的shift
引數存到了EAX暫存器裡,具體看上面的0x00007fcb529b1f0f
位置的指令
JVM裡的位移操作
從上面的位元組碼裡我們看到,當我們位移的基數是4或者4L的時候,分別看到了兩條不同的位移指令,分別是ishl
和lshl
,這兩條指令一個是將int型的值左移一定位數,一個是將long型的值左移一定位數,那這兩條指令分別有什麼區別呢?
JVM裡ishl指令實現
先看定義
對於ishl
指令主要實現在iop2方法裡,並且傳遞一個引數shl
因此主要實現其實就是
主要是將RAX暫存器裡的值(其實就是doShiftL函式的shift引數)存入到RCX暫存器裡(注意這裡用的movl,其實是用的32位暫存器),然後將運算元棧頂的值(就是上述的4)存到RAX裡,並做shll操作!
那問題就來了,這裡的0xD3,0xE0到底是什麼鬼,不過我們能猜到是做的位移操作,那我們看看ishl完整的彙編程式碼
上述的0x00007fcb529b5930
其實就應該是上面的Assembler::shll
的輸出了,裡面有CL暫存器(RCX暫存器的低32位是ECX,而ECX的低8位是CL,這個關係清楚了吧)和EAX暫存器,看到這指令其實可以解釋了,CL暫存器因為是ECX暫存器的低8位,而我們從上面得知RCX裡存的其實是要位移的位數,也就是上面Demo裡的doShiftL
函式的shift
引數值,而EAX暫存器裡的值是運算元棧頂的值,也就是4
那現在的問題是明明我們就傳了一個RAX的暫存器給Assembler::shll
,那怎麼操作起CL暫存器來了,這其實就是我想寫本文的根本原因,我想解釋這個現象,還想知道0xD3,0xE0
到底是什麼鬼,於是找了intel指令手冊,看到SHL指令這樣的描述
0xD3的二進位制表示是1101 0011
,和上面的1101 001w
是匹配的,這個w應該是如果是暫存器定址,那就是1吧
0xE0的二進位制表示是1110 0000
,和上面的11 100 reg
是匹配的,也就是reg佔3位,那問題是暫存器個數並不只有8個,因此超過8個的情況怎麼表示呢,那來看看encode的過程
這裡的關鍵其實就是prefix的值了,通過設定prefix來看是否使用了普通暫存器之外的暫存器,這個大家網上可以找找相關資料看看,是X86的擴充套件64位技術
另外從上面的規範裡我們看到了CL暫存器,也就是shl命令本身就是和CL暫存器緊密結合實現的(其中一種定址方式而已),另外將shel之後的結果存到EAX暫存器裡,再次提醒下是32位的暫存器,而和下面說的lshl的最大區別就是其使用的其實是64位的RAX暫存器,因此兩者表示的最大值顯然不一樣啦
JVM裡lshl指令實現
先看定義
lshl指令主要實現在lshl方法裡
而pop_l的實現如下,使用了movq,也就是移動棧上的雙字(8byte=64位,用RAX暫存器存)到暫存器裡,注意上面的ishl使用的是movl,是移動長字到暫存器裡(即4byte=32位,正好用EAX暫存器存),
lshl的彙編實現:
從這裡也印證了確實用了RAX暫存器(請看0x00007fcb529b59b1
)
總結
這篇文章因為涉及到太多的彙編指令,可能不少人看起來不是很明白,不過我覺得你可以多看幾遍啦,看多了也許就看懂了,不過實現看不下去沒關係,就看看小結吧
- 當我們要位移的基數的型別是long的時候,其實是用64位的RAX暫存器來操作的,因此存的最大值(2^64-1)會更大,而如果基礎是int的話,會用32位的EAX暫存器,因此能存的最大值(2^32-1)會小點,超過了閾值就會溢位
- 使用了8位的CL暫存器來存要位移的位數,因此最大其實就是2^8-1=255啦,所以上述demo,如果我們將shift的引數從35改成291發現結果是一樣的
推薦閱讀: