從X86指令深扒JVM的位移操作

PerfMa發表於2020-05-19

概述

之所以會寫這個,主要是因為最近做的一個專案碰到了一個移位的問題,因為位移操作溢位導致結果不準確,本來可以點到為止,問題也能很快解決,但是不痛不癢的感覺著實讓人不爽,於是深扒了下箇中細節,直到看到Intel的指令規約才算釋然,希望這篇文章能引起大家共鳴。

本文或許看起來會比較枯燥,不過其實認真看挺有意思的,如果實在看不下去,告訴你一個極簡路徑,先看下下面的Demo,然後直接跳到後面的小結,如果懂了,別忘記順便點個贊,請叫我雷鋒,哈哈。

Demo

還是從一個簡單的例子說起

image.png

大家可以嘗試做幾個改變,看看結果怎樣

  • 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_4ldc2_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的大概彙編指令如下

image.png

重點看0x00007fcb529b0b30這條就是將0x4移到EAX暫存器裡,這是一個32位的暫存器,需要注意的是這裡並沒有直接將4 push到運算元棧上,而是在下一條指令(也就是iload_0)執行的時候才預先push到棧上,後面看iload_0的彙編程式碼可知

ldc2_w

ldc2_w是將long或者double的常量值從常量池推到運算元棧頂,其大概彙編指令如下

image.png

重點看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的彙編實現大致如下:

image.png

這條指令簡單來說就是將方法的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的時候,分別看到了兩條不同的位移指令,分別是ishllshl,這兩條指令一個是將int型的值左移一定位數,一個是將long型的值左移一定位數,那這兩條指令分別有什麼區別呢?

JVM裡ishl指令實現

先看定義

image.png

對於ishl指令主要實現在iop2方法裡,並且傳遞一個引數shl

image.png

因此主要實現其實就是

image.png

主要是將RAX暫存器裡的值(其實就是doShiftL函式的shift引數)存入到RCX暫存器裡(注意這裡用的movl,其實是用的32位暫存器),然後將運算元棧頂的值(就是上述的4)存到RAX裡,並做shll操作!

image.png

那問題就來了,這裡的0xD3,0xE0到底是什麼鬼,不過我們能猜到是做的位移操作,那我們看看ishl完整的彙編程式碼

image.png

上述的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指令這樣的描述

image.png

0xD3的二進位制表示是1101 0011,和上面的1101 001w是匹配的,這個w應該是如果是暫存器定址,那就是1吧

0xE0的二進位制表示是1110 0000,和上面的11 100 reg是匹配的,也就是reg佔3位,那問題是暫存器個數並不只有8個,因此超過8個的情況怎麼表示呢,那來看看encode的過程

image.png

這裡的關鍵其實就是prefix的值了,通過設定prefix來看是否使用了普通暫存器之外的暫存器,這個大家網上可以找找相關資料看看,是X86的擴充套件64位技術

另外從上面的規範裡我們看到了CL暫存器,也就是shl命令本身就是和CL暫存器緊密結合實現的(其中一種定址方式而已),另外將shel之後的結果存到EAX暫存器裡,再次提醒下是32位的暫存器,而和下面說的lshl的最大區別就是其使用的其實是64位的RAX暫存器,因此兩者表示的最大值顯然不一樣啦

JVM裡lshl指令實現

先看定義

image.png

lshl指令主要實現在lshl方法裡

image.png

而pop_l的實現如下,使用了movq,也就是移動棧上的雙字(8byte=64位,用RAX暫存器存)到暫存器裡,注意上面的ishl使用的是movl,是移動長字到暫存器裡(即4byte=32位,正好用EAX暫存器存),

image.png

lshl的彙編實現:

image.png

從這裡也印證了確實用了RAX暫存器(請看0x00007fcb529b59b1)

總結

這篇文章因為涉及到太多的彙編指令,可能不少人看起來不是很明白,不過我覺得你可以多看幾遍啦,看多了也許就看懂了,不過實現看不下去沒關係,就看看小結吧

  • 當我們要位移的基數的型別是long的時候,其實是用64位的RAX暫存器來操作的,因此存的最大值(2^64-1)會更大,而如果基礎是int的話,會用32位的EAX暫存器,因此能存的最大值(2^32-1)會小點,超過了閾值就會溢位

  • 使用了8位的CL暫存器來存要位移的位數,因此最大其實就是2^8-1=255啦,所以上述demo,如果我們將shift的引數從35改成291發現結果是一樣的

 

推薦閱讀:

PerfMa KO 系列之 JVM 引數【Memory篇】

執行緒池運用不當的一次線上事故

 

相關文章